chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
/**
* Local-dev seed for the checks pages: a few more commits on the demo
* repository, and the check records a runner would have published about them.
* The account runs as its own runner, which is what the config record's
* `runner` field names. A reader resolves that DID and reads its checks, so
* one account playing both parts exercises the same path as two.
*
* The runs are shaped to cover what the pages have to render: several
* workflows on one commit, a workflow re-run, a failure beside a success, and
* a run still going.
*
* Run `node scripts/dev-git-demo.mjs` first; this pushes onto the repository
* that leaves behind. See `just dev-git`, which runs all three.
*
* Usage: node scripts/dev-git-checks.mjs [repo-name]
* Env: PDS_DEV_URL (default http://localhost:2471)
* PDS_DEV_PASSWORD (default test-password)
* PDS_DEV_PLC_URL (default http://localhost:2582)
* PDS_DEV_WORK_DIR (default .dev-pds/work)
*/
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { resolve } from 'node:path';
const BASE = process.env.PDS_DEV_URL || 'http://localhost:2471';
const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';
const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582';
const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/work');
const NAME = process.argv[2] || 'demo-app';
const CHECK_COLLECTION = 'dev.pdsjs.git.check';
const LATEST_CHECK_COLLECTION = 'dev.pdsjs.git.latestCheck';
const REF = 'refs/heads/main';
/** @param {string} command @param {string[]} args @param {object} [options] */
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: `${WORK}/${NAME}`,
stdio: ['ignore', 'pipe', 'pipe'],
encoding: 'utf8',
...options,
env: { ...process.env, ...(options.env ?? {}) },
});
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`,
);
}
return result.stdout?.trim() ?? '';
}
const did = (await (await fetch(`${BASE}/.well-known/atproto-did`)).text())
.trim()
.replace(/^"|"$/g, '');
const session = await (
await fetch(`${BASE}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: did, password: PASSWORD }),
})
).json();
const AUTH = { Authorization: `Bearer ${session.accessJwt}` };
/** @param {string} path @param {unknown} body */
async function call(path, body) {
const res = await fetch(`${BASE}/xrpc/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...AUTH },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${path} answered ${res.status}`);
return res.json();
}
// ---- 1. a history worth listing ------------------------------------------
const COMMITS = [
['README.md', '\nBuilt by its own runner.\n', 'document the runner'],
['index.js', 'export const version = () => "1.0.1";\n', 'add version()'],
[
'index.js',
'export const shout = (who) => `HELLO, ${who}`;\n',
'add shout()',
],
];
for (const [file, body, message] of COMMITS) {
appendFileSync(`${WORK}/${NAME}/${file}`, body);
run('git', ['add', '-A']);
run('git', [
'-c',
'user.email=alice@localhost',
'-c',
'user.name=Alice',
'commit',
'-qm',
message,
]);
}
run('git', ['push', '-q', `atproto://${did}/${NAME}`, 'main'], {
env: {
PATH: `${WORK}/bin:${process.env.PATH}`,
ATPROTO_GIT_IDENTIFIER: did,
ATPROTO_GIT_PASSWORD: PASSWORD,
ATPROTO_GIT_PLC_URL: PLC_URL,
ATPROTO_GIT_SERVICE: BASE,
},
});
// Newest first, which is the order the runs below are written in.
const shas = run('git', ['log', '-4', '--format=%H']).split('\n');
console.log(`Pushed ${COMMITS.length} more commits to ${NAME}`);
// ---- 2. what the runner would have published ------------------------------
// The key is minted, so the listing is what joins the name to the record.
const repoRecord = (
await (
await fetch(
`${BASE}/xrpc/com.atproto.repo.listRecords?repo=${did}&collection=dev.pdsjs.git.repo&limit=100`,
)
).json()
).records.find((row) => row.value.name === NAME);
if (!repoRecord) throw new Error(`no repository named ${NAME} to check`);
const repoKey = repoRecord.uri.split('/').pop();
const subject = { uri: repoRecord.uri, cid: repoRecord.cid };
/** A step's line in the log, the way a shell runner writes one. */
const logFor = (steps) =>
steps
.map(({ name, exitCode }) =>
exitCode === 0
? `$ ${name}\nran ${name} in the checkout\n${name}: ok\n`
: `$ ${name}\nran ${name} in the checkout\n${name}: FAILED\nexit status ${exitCode}\n`,
)
.join('\n');
/** @param {string} text */
async function uploadLog(text) {
const res = await fetch(`${BASE}/xrpc/com.atproto.repo.uploadBlob`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain', ...AUTH },
body: text,
});
if (!res.ok) throw new Error(`uploadBlob answered ${res.status}`);
return (await res.json()).blob;
}
const MINUTE = 60_000;
let clock = Date.now() - 90 * MINUTE;
/**
* One run, written the way the runner writes one.
* @param {{sha: string, workflow: string, steps: {name: string, exitCode: number, durationMs: number}[], running?: boolean}} spec
*/
async function publish({ sha, workflow, steps, running }) {
const startedAt = new Date(clock).toISOString();
const took = steps.reduce((total, step) => total + step.durationMs, 0);
clock += took + MINUTE;
const failed = steps.find((step) => step.exitCode !== 0);
const status = running ? 'running' : failed ? 'failure' : 'success';
const record = {
$type: CHECK_COLLECTION,
subject,
ref: REF,
sha,
workflow,
status,
steps,
startedAt,
...(running
? {}
: {
finishedAt: new Date(Date.parse(startedAt) + took).toISOString(),
logs: await uploadLog(logFor(steps)),
}),
};
const { uri, cid } = await call('com.atproto.repo.createRecord', {
repo: did,
collection: CHECK_COLLECTION,
record,
});
return { workflow, subject, check: { uri, cid }, sha, status, startedAt };
}
const step = (name, exitCode, durationMs) => ({ name, exitCode, durationMs });
const install = step('pnpm install --frozen-lockfile', 0, 21_400);
// Oldest commit first, so the runs read in the order they happened.
const [head, second, third, first] = shas;
const written = [];
for (const spec of [
{ sha: first, workflow: 'ci', steps: [install, step('npm test', 0, 46_800)] },
{ sha: third, workflow: 'ci', steps: [install, step('npm test', 0, 51_200)] },
{
sha: third,
workflow: 'lint',
steps: [install, step('biome check .', 0, 3_900)],
},
{
sha: second,
workflow: 'ci',
steps: [install, step('npm test', 1, 38_500)],
},
// The same workflow again, which is what a re-run leaves behind.
{
sha: second,
workflow: 'ci',
steps: [install, step('npm test', 0, 44_100)],
},
{
sha: second,
workflow: 'lint',
steps: [install, step('biome check .', 0, 4_200)],
},
{
sha: second,
workflow: 'publish',
steps: [install, step('npm publish', 0, 12_600)],
},
{
sha: head,
workflow: 'lint',
steps: [install, step('biome check .', 0, 4_050)],
},
{
sha: head,
workflow: 'ci',
steps: [
install,
step('npm test', 1, 62_300),
step('npm run typecheck', 0, 9_100),
],
},
{
sha: head,
workflow: 'publish',
steps: [install, step('npm publish', 0, 0)],
running: true,
},
]) {
written.push(await publish(spec));
}
console.log(`Published ${written.length} check records as ${did}`);
// ---- 3. the record a reader asks for by name ------------------------------
/** The newest entry per workflow, which is what this record holds. */
const latest = new Map();
for (const entry of written) latest.set(entry.workflow, entry);
const RKEY_SAFE = /[^A-Za-z0-9._~:-]/g;
await call('com.atproto.repo.putRecord', {
repo: did,
collection: LATEST_CHECK_COLLECTION,
rkey: `${did}:${repoKey}:${REF}`.replace(RKEY_SAFE, '_'),
record: {
$type: LATEST_CHECK_COLLECTION,
ref: REF,
checks: [...latest.values()],
},
});
// ---- 4. the repository names its runner -----------------------------------
const config = await (
await fetch(
`${BASE}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=dev.pdsjs.git.config&rkey=${repoKey}`,
)
).json();
await call('com.atproto.repo.putRecord', {
repo: did,
collection: 'dev.pdsjs.git.config',
rkey: repoKey,
record: { ...config.value, runner: did },
});
console.log(`${NAME} names ${did} as its runner`);
// ---- 5. a palette the account publishes ----------------------------------
// Proves the theme record path: the page ships four palettes and reads any
// the account publishes beside them.
await call('com.atproto.repo.putRecord', {
repo: did,
collection: 'dev.pdsjs.app.theme',
rkey: 'ember',
record: {
$type: 'dev.pdsjs.app.theme',
name: 'Ember',
scheme: 'dark',
colors: {
background: '#160f0c',
card: '#1d1512',
muted: '#1d1512',
accent: '#2a1e19',
border: '#33241d',
input: '#4a3529',
foreground: '#f4e7de',
'muted-foreground': '#b39b8c',
faint: '#7d6357',
key: '#e8873c',
ring: '#e8873c',
destructive: '#e2564a',
success: '#8fb15c',
'diff-add': '#1d2416',
'diff-del': '#2a1614',
'code-keyword': '#e8873c',
'code-string': '#8fb15c',
'code-number': '#e0b64a',
'code-title': '#f4e7de',
'code-comment': '#7d6357',
},
createdAt: new Date().toISOString(),
},
});
console.log(`${did} publishes the Ember palette`);
// ---- 6. an identity the account claims -----------------------------------
// The commits above are co-authored, and the tool that co-wrote them holds no
// atproto account. The account says what to call it, which is what lets a
// facepile name it. A real one would give it a face too, as an avatar blob.
await call('com.atproto.repo.putRecord', {
repo: did,
collection: 'dev.pdsjs.git.identity',
rkey: 'self',
record: {
$type: 'dev.pdsjs.git.identity',
idents: [
{ email: 'alice@localhost', did },
{ email: 'noreply@anthropic.com', label: 'Claude' },
],
createdAt: new Date().toISOString(),
},
});
console.log('Claimed the identities behind the commits');