chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
// Publish the built wizard as a site on a PDS. The page, its assets and a
// config.json become files in a dev.pdsjs.site.deploy record; the site's
// fallback entry serves the page for /callback and /update, the app's two
// client-side routes.
//
// Build first: npm run build (in packages/start).
//
// Environment:
// PDS_URL e.g. https://pds.pdsjs.dev
// PDS_IDENTIFIER handle or DID of the publishing account
// PDS_PASSWORD account password or an app password
// SITE_NAME record key and subdomain (default: start)
// OAUTH_CLIENT_ID the wizard's OAuth client id
// OAUTH_SCOPES space-separated scope names
// ARTIFACT_URL optional, default https://artifacts.pdsjs.dev/latest
// CF_API_URL optional, default /cf-api (the PDS's passthrough)
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const distDir = join(appDir, 'dist');
const siteName = process.env.SITE_NAME ?? 'start';
const pdsUrl = process.env.PDS_URL;
const identifier = process.env.PDS_IDENTIFIER;
const password = process.env.PDS_PASSWORD;
const clientId = process.env.OAUTH_CLIENT_ID;
const scopes = process.env.OAUTH_SCOPES;
if (!pdsUrl || !identifier || !password || !clientId || !scopes) {
console.error(
'Set PDS_URL, PDS_IDENTIFIER, PDS_PASSWORD, OAUTH_CLIENT_ID and OAUTH_SCOPES.',
);
process.exit(1);
}
const COLLECTION = 'dev.pdsjs.site.deploy';
/** Content-Types by extension, for what a vite build emits. */
const TYPES = new Map([
['html', 'text/html; charset=utf-8'],
['css', 'text/css; charset=utf-8'],
['js', 'text/javascript; charset=utf-8'],
['json', 'application/json'],
['svg', 'image/svg+xml'],
['png', 'image/png'],
['ico', 'image/x-icon'],
['txt', 'text/plain; charset=utf-8'],
['woff2', 'font/woff2'],
]);
/** @param {string} path */
function contentType(path) {
const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
return TYPES.get(ext) ?? 'application/octet-stream';
}
/** @param {string} dir @returns {string[]} */
function walk(dir) {
return readdirSync(dir).flatMap((name) => {
const full = join(dir, name);
return statSync(full).isDirectory() ? walk(full) : [full];
});
}
/** @param {string} path @param {RequestInit} init */
async function xrpc(path, init = {}) {
const response = await fetch(`${pdsUrl}/xrpc/${path}`, init);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(
`${path}: ${response.status} ${body.message ?? body.error ?? ''}`,
);
}
return body;
}
const session = await xrpc('com.atproto.server.createSession', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
const auth = { Authorization: `Bearer ${session.accessJwt}` };
/** @param {Uint8Array} bytes @param {string} type */
async function uploadBlob(bytes, type) {
const { blob } = await xrpc('com.atproto.repo.uploadBlob', {
method: 'POST',
headers: { ...auth, 'Content-Type': type },
body: bytes,
});
return blob;
}
const IMMUTABLE = 'public, max-age=31536000, immutable';
const SHORT = 'public, max-age=300';
const files = [];
for (const full of walk(distDir)) {
const path = relative(distDir, full);
const blob = await uploadBlob(readFileSync(full), contentType(path));
files.push({
path,
blob,
contentType: contentType(path),
// Vite hashes everything under assets/; the rest moves between deploys.
cacheControl: path.startsWith('assets/') ? IMMUTABLE : SHORT,
});
}
const config = {
clientId,
scopes,
cfApiUrl: process.env.CF_API_URL ?? '/cf-api',
artifactUrl: process.env.ARTIFACT_URL ?? 'https://artifacts.pdsjs.dev/latest',
};
const configBlob = await uploadBlob(
new TextEncoder().encode(`${JSON.stringify(config, null, 2)}\n`),
'application/json',
);
files.push({
path: 'config.json',
blob: configBlob,
contentType: 'application/json',
cacheControl: SHORT,
});
await xrpc('com.atproto.repo.putRecord', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
repo: session.did,
collection: COLLECTION,
rkey: siteName,
record: {
$type: COLLECTION,
files,
// /callback and /update are client-side routes; the page answers them.
fallback: 'index.html',
createdAt: new Date().toISOString(),
},
}),
});
console.log(
`Published the wizard to the "${siteName}" site (${files.length} files).`,
);