chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// The Cloudflare half of setup: bucket, worker upload, secrets, hostname.
// Every function is safe to run again; a retry resumes without damage.
/** @typedef {ReturnType<typeof import('./cf.js').cfClient>} Cf */
/**
* Create the blob bucket. An existing bucket with the same name is fine.
* @param {Cf} cf
* @param {string} accountId
* @param {string} name
*/
export async function createBucket(cf, accountId, name) {
try {
await cf.post(`/client/v4/accounts/${accountId}/r2/buckets`, { name });
} catch (err) {
const codes = /** @type {any} */ (err).codes ?? [];
// 10004: a bucket with this name already exists.
if (!codes.includes(10004)) throw err;
}
}
/**
* The account's workers.dev subdomain, or null when none is set.
* @param {Cf} cf
* @param {string} accountId
* @returns {Promise<string|null>}
*/
export async function getWorkersSubdomain(cf, accountId) {
try {
const result = await cf.get(
`/client/v4/accounts/${accountId}/workers/subdomain`,
);
return result?.subdomain ?? null;
} catch {
return null;
}
}
/**
* @param {Cf} cf
* @param {string} accountId
* @param {string} scriptName
*/
async function scriptExists(cf, accountId, scriptName) {
try {
await cf.get(
`/client/v4/accounts/${accountId}/workers/scripts/${scriptName}/settings`,
);
return true;
} catch {
return false;
}
}
/**
* The published release: the manifest and the module, from the artifacts
* site.
* @param {string} artifactUrl - e.g. https://artifacts.pdsjs.dev/latest
*/
async function fetchArtifact(artifactUrl) {
const [moduleResponse, manifestResponse] = await Promise.all([
fetch(`${artifactUrl}/index.js`),
fetch(`${artifactUrl}/manifest.json`),
]);
if (!moduleResponse.ok || !manifestResponse.ok) {
throw new Error(`No release artifact answered at ${artifactUrl}`);
}
return {
manifest: await manifestResponse.json(),
moduleCode: await moduleResponse.blob(),
};
}
/**
* Upload the pds.js worker with its bindings.
* @param {Cf} cf
* @param {{accountId: string, scriptName: string, bucketName: string, hostname: string, updateUrl: string, artifactUrl: string, relayUrl: string}} opts
*/
export async function uploadWorker(cf, opts) {
const { manifest, moduleCode } = await fetchArtifact(opts.artifactUrl);
// The Durable Object migration applies once. A script that already exists
// carries its migration history, and repeating the tag is an API error, so
// a retry uploads without it.
const exists = await scriptExists(cf, opts.accountId, opts.scriptName);
const metadata = {
main_module: manifest.mainModule,
compatibility_date: manifest.compatibilityDate,
compatibility_flags: manifest.compatibilityFlags,
bindings: [
...manifest.durableObjects.map(
(/** @type {{name: string, className: string}} */ d) => ({
type: 'durable_object_namespace',
name: d.name,
class_name: d.className,
}),
),
{
type: 'r2_bucket',
name: 'BLOBS',
bucket_name: opts.bucketName,
},
{ type: 'plain_text', name: 'HOSTNAME', text: opts.hostname },
// The account page links here when a newer release is published.
{ type: 'plain_text', name: 'PDS_UPDATE_URL', text: opts.updateUrl },
// The name this script deploys under. The update link carries it, so
// the update page can find the worker when the PDS later answers on a
// custom domain.
{ type: 'plain_text', name: 'PDS_SCRIPT_NAME', text: opts.scriptName },
// The relay the server asks to crawl it. Without this the server
// announces itself to nobody and no account here federates.
{ type: 'plain_text', name: 'RELAY_URL', text: opts.relayUrl },
],
...(exists ? {} : { migrations: manifest.migrations }),
observability: { enabled: true },
};
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' }),
);
form.append(
manifest.mainModule,
new Blob([moduleCode], { type: 'application/javascript+module' }),
manifest.mainModule,
);
await cf.putForm(
`/client/v4/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
form,
);
return manifest;
}
/**
* @param {Cf} cf
* @param {string} accountId
* @param {string} scriptName
* @param {Record<string, string>} secrets
*/
export async function putSecrets(cf, accountId, scriptName, secrets) {
for (const [name, text] of Object.entries(secrets)) {
await cf.put(
`/client/v4/accounts/${accountId}/workers/scripts/${scriptName}/secrets`,
{ name, text, type: 'secret_text' },
);
}
}
/**
* Re-upload an existing worker with the current artifact. The worker's own
* bindings and vars come from its settings and go back unchanged, whatever
* the wizard or a later hand edit put there; the secrets stay through
* keep_bindings. Two vars are exceptions. The update refreshes
* PDS_SCRIPT_NAME with the name it found the worker under, and adds
* RELAY_URL to a worker that has none, so a deploy made before either var
* existed gains it. No migrations: the Durable Object class exists.
* Returns the manifest it uploaded. The caller waits for the server to report
* that version, which is not always the one the caller saw when it checked:
* the canary channel is a single moving pointer, so a release can land
* between the check and this upload.
* @param {Cf} cf
* @param {{accountId: string, scriptName: string, artifactUrl: string, relayUrl: string}} opts
* @returns {Promise<{version: string, mainModule: string}>} the manifest installed
*/
export async function updateWorker(cf, opts) {
const { manifest, moduleCode } = await fetchArtifact(opts.artifactUrl);
const settings = await cf.get(
`/client/v4/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}/settings`,
);
const bindings = (settings?.bindings ?? []).filter(
(/** @type {{type: string, name?: string}} */ b) =>
b.type !== 'secret_text' && b.name !== 'PDS_SCRIPT_NAME',
);
bindings.push({
type: 'plain_text',
name: 'PDS_SCRIPT_NAME',
text: opts.scriptName,
});
const hasRelay = bindings.some(
(/** @type {{name?: string}} */ b) => b.name === 'RELAY_URL',
);
if (!hasRelay) {
bindings.push({
type: 'plain_text',
name: 'RELAY_URL',
text: opts.relayUrl,
});
}
const metadata = {
main_module: manifest.mainModule,
compatibility_date: manifest.compatibilityDate,
compatibility_flags: manifest.compatibilityFlags,
bindings,
keep_bindings: ['secret_text'],
observability: { enabled: true },
};
const form = new FormData();
form.append(
'metadata',
new Blob([JSON.stringify(metadata)], { type: 'application/json' }),
);
form.append(
manifest.mainModule,
new Blob([moduleCode], { type: 'application/javascript+module' }),
manifest.mainModule,
);
await cf.putForm(
`/client/v4/accounts/${opts.accountId}/workers/scripts/${opts.scriptName}`,
form,
);
return manifest;
}
/**
* The account's active zones, for the domain picker.
* @param {Cf} cf
* @returns {Promise<{id: string, name: string}[]>}
*/
export async function listZones(cf) {
const zones = await cf.get('/client/v4/zones?per_page=50&status=active');
return (zones ?? []).map((/** @type {{id: string, name: string}} */ z) => ({
id: z.id,
name: z.name,
}));
}
/**
* Attach a hostname in the visitor's zone to their worker. Cloudflare
* creates the DNS record and the certificate; the certificate can take a
* minute to provision.
* @param {Cf} cf
* @param {{accountId: string, zoneId: string, hostname: string, scriptName: string}} opts
*/
export async function attachCustomDomain(cf, opts) {
await cf.put(`/client/v4/accounts/${opts.accountId}/workers/domains`, {
environment: 'production',
hostname: opts.hostname,
service: opts.scriptName,
zone_id: opts.zoneId,
});
}
/**
* Prove the handle with a _atproto TXT record, the resolution path that
* needs no page at the apex: the domain's site, if any, stays untouched.
* Safe to run again; an existing record updates in place.
* @param {Cf} cf
* @param {{zoneId: string, zoneName: string, did: string}} opts
*/
export async function putHandleRecord(cf, opts) {
const name = `_atproto.${opts.zoneName}`;
const content = `did=${opts.did}`;
const existing = await cf.get(
`/client/v4/zones/${opts.zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}`,
);
const record = (existing ?? [])[0];
if (record) {
await cf.patch(`/client/v4/zones/${opts.zoneId}/dns_records/${record.id}`, {
content,
});
return;
}
await cf.post(`/client/v4/zones/${opts.zoneId}/dns_records`, {
type: 'TXT',
name,
content,
ttl: 300,
});
}
/**
* Serve the script on its workers.dev hostname.
* @param {Cf} cf
* @param {string} accountId
* @param {string} scriptName
*/
export async function enableSubdomain(cf, accountId, scriptName) {
await cf.post(
`/client/v4/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`,
{ enabled: true, previews_enabled: false },
);
}
/**
* Wait until the fresh worker answers /status.
* @param {string} pdsUrl
*/
export async function waitForPds(pdsUrl, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(`${pdsUrl}/status`);
if (response.ok) return await response.json();
} catch {
// The hostname is not resolvable yet.
}
await new Promise((resolve) => setTimeout(resolve, 3000));
}
throw new Error(
`The new server did not answer at ${pdsUrl} within ${timeoutMs / 1000}s`,
);
}