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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
247 lines · 7.9 KB Raw History
// A tiny XRPC client for talking to an arbitrary PDS during migration: the
// user's old server (cross-origin) and this one (same-origin). Deliberately
// standalone — the account /account/api helper is same-origin + session-cookie
// only, and migration has to reach another host with a bearer token.

const PUBLIC_APPVIEW = 'https://public.api.bsky.app';
const PLC_DIRECTORY = 'https://plc.directory';

// A JSON call that stops making progress is almost always a dropped socket, so
// give up quickly and let the caller retry. Binary transfers (repo CARs, blobs)
// can legitimately take longer, so they're bounded by a *stall* timeout — no new
// bytes for this long means the stream is wedged (a symptom we hit migrating a
// blob the source PDS could only half-read) — plus a generous overall backstop.
const JSON_TIMEOUT_MS = 30_000;
const BINARY_STALL_MS = 20_000;
const BINARY_OVERALL_MS = 300_000;

export class XrpcError extends Error {
  /**
   * @param {number} status
   * @param {string} [error]
   * @param {string} [message]
   */
  constructor(status, error, message) {
    super(message || error || `Request failed (${status})`);
    this.name = 'XrpcError';
    this.status = status;
    this.error = error || '';
  }
}

/**
 * True for the atproto "second factor required / wrong code" error.
 * @param {unknown} err
 */
export function isAuthFactorError(err) {
  return (
    err instanceof XrpcError &&
    (err.error === 'AuthFactorTokenRequired' ||
      /sign in code|auth ?factor/i.test(err.message))
  );
}

/**
 * True for a 429 from either side.
 * @param {unknown} err
 */
export function isRateLimitError(err) {
  return err instanceof XrpcError && err.status === 429;
}

/**
 * A client bound to one PDS base URL. `''` means this origin (same-origin).
 * @param {string} base
 */
export function xrpc(base) {
  const root = base.replace(/\/+$/, '');
  /**
   * @param {string} nsid
   * @param {{ method?: string, params?: Record<string, string>, body?: any, token?: string, binary?: boolean, contentType?: string }} [opts]
   */
  async function call(nsid, opts = {}) {
    const { method = 'GET', params, body, token, binary, contentType } = opts;
    let url = `${root}/xrpc/${nsid}`;
    if (params) url += `?${new URLSearchParams(params)}`;

    /** @type {Record<string, string>} */
    const headers = {};
    if (token) headers.Authorization = `Bearer ${token}`;
    /** @type {BodyInit | undefined} */
    let payload;
    if (body instanceof Uint8Array) {
      headers['Content-Type'] = contentType || 'application/octet-stream';
      payload = /** @type {Uint8Array<ArrayBuffer>} */ (body);
    } else if (body !== undefined) {
      headers['Content-Type'] = 'application/json';
      payload = JSON.stringify(body);
    }

    const controller = new AbortController();
    if (binary) {
      // Overall backstop; the finer stall guard lives in readWithStall.
      const overall = setTimeout(() => controller.abort(), BINARY_OVERALL_MS);
      try {
        const res = await fetch(url, {
          method,
          headers,
          body: payload,
          signal: controller.signal,
        });
        if (!res.ok) throw await toError(res);
        const bytes = await readWithStall(res, controller);
        return { bytes, res };
      } finally {
        clearTimeout(overall);
      }
    }
    const timer = setTimeout(() => controller.abort(), JSON_TIMEOUT_MS);
    try {
      const res = await fetch(url, {
        method,
        headers,
        body: payload,
        signal: controller.signal,
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new XrpcError(res.status, data.error, data.message);
      return data;
    } finally {
      clearTimeout(timer);
    }
  }
  return { base: root, call };
}

/**
 * Drain a binary response, aborting if the body goes quiet for BINARY_STALL_MS.
 * A stalled read (the server sent headers + some bytes, then wedged) otherwise
 * hangs forever; aborting turns it into a rejection the caller can retry. If a
 * Content-Length is present we stop as soon as we've read it all, so a server
 * that delivers every byte but never closes the stream still resolves.
 * @param {Response} res
 * @param {AbortController} controller
 */
async function readWithStall(res, controller) {
  if (!res.body) return new Uint8Array(await res.arrayBuffer());
  const lenHeader = res.headers.get('content-length');
  const expected = lenHeader ? Number(lenHeader) : null;
  const reader = res.body.getReader();
  /** @type {Uint8Array[]} */
  const chunks = [];
  let total = 0;
  /** @type {ReturnType<typeof setTimeout> | undefined} */
  let stall;
  const arm = () => {
    clearTimeout(stall);
    stall = setTimeout(() => controller.abort(), BINARY_STALL_MS);
  };
  try {
    arm();
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        chunks.push(value);
        total += value.length;
        arm();
        if (expected != null && total >= expected) break;
      }
    }
  } finally {
    clearTimeout(stall);
  }
  const out = new Uint8Array(total);
  let offset = 0;
  for (const chunk of chunks) {
    out.set(chunk, offset);
    offset += chunk.length;
  }
  return out;
}

/** @param {Response} res */
async function toError(res) {
  const data = await res.json().catch(() => ({}));
  return new XrpcError(res.status, data.error, data.message);
}

/**
 * Resolve a DID to its DID document (did:plc via the directory, did:web via
 * well-known).
 * @param {string} did
 */
async function resolveDidDoc(did) {
  let url;
  if (did.startsWith('did:plc:')) {
    url = `${PLC_DIRECTORY}/${did}`;
  } else if (did.startsWith('did:web:')) {
    url = `https://${did.slice('did:web:'.length)}/.well-known/did.json`;
  } else {
    throw new Error(`Unsupported DID method: ${did}`);
  }
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Could not resolve ${did}`);
  return res.json();
}

/**
 * Pull the atproto PDS endpoint out of a DID document.
 * @param {{service?: {id: string, type: string, serviceEndpoint?: string}[]}|null} didDoc
 */
export function pdsEndpointOf(didDoc) {
  const service = (didDoc?.service || []).find(
    (/** @type {{id: string, type: string}} */ s) =>
      s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer',
  );
  return service?.serviceEndpoint || null;
}

/**
 * Resolve a handle or DID to `{ did, pdsUrl }`. Handles resolve through the
 * public AppView (works for any handle in the network without DNS access from
 * the browser); the DID then resolves to its hosting PDS.
 * @param {string} identifier
 * @returns {Promise<{ did: string, pdsUrl: string }>}
 */
export async function resolveAccount(identifier) {
  const value = identifier.trim().replace(/^@/, '');
  let did = value;
  if (!value.startsWith('did:')) {
    const res = await fetch(
      `${PUBLIC_APPVIEW}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(value)}`,
    );
    const data = await res.json().catch(() => ({}));
    if (!res.ok || !data.did) {
      throw new Error(`Could not find an account for "${value}".`);
    }
    did = data.did;
  }
  const doc = await resolveDidDoc(did);
  const pdsUrl = pdsEndpointOf(doc);
  if (!pdsUrl) throw new Error(`No PDS is listed for ${did}.`);
  return { did, pdsUrl };
}

/**
 * The most recent PDS endpoint in a DID's PLC history that isn't `exclude` —
 * used to rediscover the old PDS when resuming with only the new one known.
 * @param {string} did
 * @param {string} exclude
 */
export async function previousPdsEndpoint(did, exclude) {
  const res = await fetch(`${PLC_DIRECTORY}/${did}/log`);
  if (!res.ok) return null;
  const log = await res.json();
  for (let i = log.length - 1; i >= 0; i--) {
    const endpoint =
      log[i]?.operation?.services?.atproto_pds?.endpoint ||
      log[i]?.services?.atproto_pds?.endpoint;
    if (
      endpoint &&
      endpoint.replace(/\/+$/, '') !== exclude.replace(/\/+$/, '')
    )
      return endpoint;
  }
  return null;
}