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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
248 lines · 7.7 KB Raw History
// Track Tuesdays: a run club on permissioned data.
//
// Boots two real PDS instances — Maya the organizer on one, Sam on the other —
// creates a club space, logs runs into each member's own repo, then serves a
// feed assembled the way a real application would: space credential, writer
// set, per-member repo pull, client-side verification. See sync.js.
//
// Requires the local PLC directory:  docker compose up -d
// Then:                              npm run dev:runclub

import { execFile } from 'node:child_process';
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { createServer as createHttpServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import { createServer } from '@pdsjs/node';
import { renderFeedPage } from './page.js';
import { getSpaceCredential, syncSpace } from './sync.js';

const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const DATA = join(dirname(fileURLToPath(import.meta.url)), '.data');

const PLC_URL = process.env.PLC_URL || 'http://localhost:2582';
const APP_PORT = 2800;
const CLUB_TYPE = 'club.runclub.group';
const CLUB_SKEY = 'track-tuesdays';
const CLUB_NAME = 'Track Tuesdays';
const RUN_COLLECTION = 'club.runclub.run';
const PASSWORD = 'runclub-demo';

/** @param {string} msg */
const step = (msg) => console.log(`\n▸ ${msg}`);

/**
 * @param {string} base
 * @param {string} nsid
 * @param {any} body
 * @param {string} [token]
 */
async function xrpcPost(base, nsid, body, token) {
  const res = await fetch(`${base}/xrpc/${nsid}`, {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      ...(token ? { authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify(body),
  });
  if (!res.ok) {
    throw new Error(`${nsid} failed: ${res.status} ${await res.text()}`);
  }
  return res.json();
}

/**
 * Boot a PDS and register an account on the local PLC, the same way the
 * README quick start does.
 * @param {string} name
 * @param {number} port
 */
async function bootPds(name, port) {
  const dir = join(DATA, name);
  mkdirSync(join(dir, 'blobs'), { recursive: true });

  const { listen } = await createServer({
    dbPath: join(dir, 'pds.db'),
    blobsDir: join(dir, 'blobs'),
    jwtSecret: `runclub-demo-${name}`,
    password: PASSWORD,
    port,
    plcUrl: PLC_URL,
    spaces: true,
  });
  await listen();

  const base = `http://localhost:${port}`;
  // Async, not execFileSync: setup.js calls back into the PDS this process is
  // serving, so blocking the event loop here would deadlock against ourselves.
  await promisify(execFile)(
    'node',
    [
      join(ROOT, 'scripts/setup.js'),
      '--pds',
      base,
      '--plc-url',
      PLC_URL,
      '--handle',
      name,
    ],
    { cwd: dir },
  );
  const creds = JSON.parse(
    readFileSync(join(dir, `credentials-${name}.json`), 'utf8'),
  );

  const session = await xrpcPost(base, 'com.atproto.server.createSession', {
    identifier: creds.did,
    password: PASSWORD,
  });

  console.log(`  ${name}: ${creds.did} on ${base}`);
  return { name, base, did: creds.did, jwt: session.accessJwt };
}

/**
 * @param {{base: string, did: string, jwt: string}} account
 * @param {string} space
 * @param {number} daysAgo
 * @param {number} distanceMeters
 * @param {number} durationSec
 * @param {string} note
 */
function logRun(account, space, daysAgo, distanceMeters, durationSec, note) {
  const startedAt = new Date(
    Date.now() - daysAgo * 24 * 60 * 60 * 1000,
  ).toISOString();
  return xrpcPost(
    account.base,
    'com.atproto.space.createRecord',
    {
      space,
      repo: account.did,
      collection: RUN_COLLECTION,
      record: {
        $type: RUN_COLLECTION,
        startedAt,
        // Integers only: atproto records avoid floats, so distance is meters
        // and duration is seconds. The page derives km and pace.
        distanceMeters,
        durationSec,
        note,
      },
    },
    account.jwt,
  );
}

async function main() {
  rmSync(DATA, { recursive: true, force: true });

  try {
    await fetch(PLC_URL);
  } catch {
    console.error(
      `Cannot reach the PLC directory at ${PLC_URL}.\nStart it with: docker compose up -d`,
    );
    process.exit(1);
  }

  step('Booting two PDS instances');
  process.on('uncaughtException', (err) => {
    if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EADDRINUSE') {
      console.error(
        `${err.message}\nA previous demo is still running \u2014 stop it with: pkill -f runclub/index.js`,
      );
      process.exit(1);
    }
    throw err;
  });
  const maya = await bootPds('maya', 2801);
  const sam = await bootPds('sam', 2802);

  step('Maya creates the club and adds members');
  const { uri: space } = await xrpcPost(
    maya.base,
    'com.atproto.space.createSpace',
    {
      spaceType: CLUB_TYPE,
      skey: CLUB_SKEY,
      policy: { $type: 'com.atproto.memberspace.policy' },
    },
    maya.jwt,
  );
  console.log(`  ${space}`);
  // Maya is the club's authority, which every policy admits, so only Sam is
  // added here.
  await xrpcPost(
    maya.base,
    'com.atproto.memberspace.putMember',
    { space, did: sam.did, read: true, write: true },
    maya.jwt,
  );

  step("Members log this week's runs into their own repos");
  await logRun(maya, space, 5, 8000, 2472, '6×800 at the track');
  await logRun(maya, space, 3, 5200, 1690, 'easy shakeout');
  await logRun(maya, space, 1, 12100, 3751, 'long run, negative split');
  await logRun(sam, space, 5, 8000, 2704, '6×800, hung on');
  await logRun(sam, space, 2, 6400, 2022, 'tempo loop');
  console.log(`  3 runs on ${maya.base}, 2 runs on ${sam.base}`);

  step('The app trades a session for a space credential');
  const held = await getSpaceCredential({
    space,
    memberPds: maya.base,
    sessionJwt: maya.jwt,
    plcUrl: PLC_URL,
  });
  console.log('  credential issued by the space authority');

  const handles = { [maya.did]: 'maya', [sam.did]: 'sam' };

  const app = createHttpServer(async (_req, res) => {
    try {
      // Sync on every request so the page always reflects the space; a real
      // app would cache and lean on notifications.
      const repos = await syncSpace({ space, held, plcUrl: PLC_URL });
      const members = repos.map((r) => ({
        did: r.did,
        handle: handles[r.did] ?? r.did,
        rev: r.rev,
        hashHex: r.hashHex,
        runs: r.records
          .filter((rec) => rec.collection === RUN_COLLECTION)
          .map((rec) => rec.value),
      }));
      const html = renderFeedPage({ clubName: CLUB_NAME, space, members });
      res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
      res.end(html);
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      // The demo's identities live only as long as the PLC directory that
      // registered them. If the directory behind PLC_URL is restarted or
      // swapped (its port is shared with other local setups), resolution
      // fails here even though this process is still healthy.
      const hint = message.includes('could not resolve')
        ? '\n\nThe PLC directory no longer knows this demo\u2019s identities \u2014 ' +
          'it was likely restarted or replaced after the demo booted. ' +
          'Re-run `npm run dev:runclub` to register fresh ones.'
        : '';
      res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
      res.end(message + hint);
    }
  });

  app.listen(APP_PORT, () => {
    step(`Feed: http://localhost:${APP_PORT}`);
    console.log(
      '  assembled per request: listRepos → getRepo per member → verify → render\n',
    );
  });
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});