~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
309 lines · 9.6 KB Raw History
// 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`,
  );
}