chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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);
});