// The Cloudflare half of setup: bucket, worker upload, secrets, hostname. // Every function is safe to run again; a retry resumes without damage. /** @typedef {ReturnType} 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} */ 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} 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`, ); }