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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
463 lines · 13.6 KB Raw History
/**
 * A client for the dev.pdsjs.git methods, for a page or a tool reading a
 * repository's server. One call per method, each answering the view its
 * lexicon in api-lexicon.js describes; a refusal throws an XrpcError
 * carrying the name the server gave.
 *
 * describe() asks community.lexicon.service.describe once and keeps the
 * answer, so serves() tells a server that predates a method, or runs with
 * the file browser off, from one that answers it.
 *
 * This module must stay free of node-only imports; it runs in the browser.
 */

import { GIT_METHODS } from './api-lexicon.js';
import { XrpcError } from './xrpc-error.js';

export { XrpcError };

/**
 * @typedef {Object} RepoView
 * @property {string} name
 * @property {string} uri
 * @property {string} cid - the record version, which a read of the repository names to be answered immutably
 * @property {string|null} space
 * @property {boolean} spaceOwned
 * @property {string|null} description
 * @property {string|null} defaultBranch
 * @property {Array<{name: string, sha: string}>} refs
 * @property {number} branches
 * @property {number} tags
 * @property {number} bundles
 * @property {number} parts
 * @property {number} size
 * @property {string|null} createdAt
 * @property {string|null} updatedAt
 * @property {string[]} protectedBranches
 * @property {string|null} runner
 * @property {Array<{registry: string, name: string}>} packages
 * @property {string} cloneUrl
 * @property {string|null} httpCloneUrl
 * @property {number} [openPulls] - open pull requests, present only when the listing asked for a summary
 * @property {number} [openIssues] - open issues, present only when the listing asked for a summary
 * @property {number[]} [activity] - recent commit dates for the gauge, present only when the listing asked for a summary
 */

/**
 * @typedef {Object} TreeEntry
 * @property {string} name
 * @property {'dir'|'file'|'symlink'|'submodule'} type
 * @property {string} mode
 * @property {string} sha
 * @property {number|null} size
 */

/**
 * @typedef {Object} TreeView
 * @property {string} ref
 * @property {string} sha
 * @property {string} path
 * @property {TreeEntry[]} entries
 */

/**
 * @typedef {Object} FileView
 * @property {string} ref
 * @property {string} path
 * @property {number} size
 * @property {string} mode
 * @property {string|null} mediaType
 * @property {boolean} binary
 * @property {boolean} truncated
 * @property {string|null} text
 */

/**
 * @typedef {Object} CommitView
 * @property {string} sha
 * @property {string[]} parents
 * @property {string} author
 * @property {number} authoredAt
 * @property {number} committedAt
 * @property {string} message
 */

/**
 * @typedef {Object} DiffLine
 * @property {'context'|'add'|'delete'} type
 * @property {string} text
 * @property {number|null} oldLine
 * @property {number|null} newLine
 */

/**
 * @typedef {Object} DiffHunk
 * @property {number} oldStart
 * @property {number} oldLines
 * @property {number} newStart
 * @property {number} newLines
 * @property {DiffLine[]} lines
 */

/**
 * @typedef {Object} FileDiff
 * @property {string} path
 * @property {'added'|'deleted'|'modified'} status
 * @property {string|null} oldMode
 * @property {string|null} newMode
 * @property {number|null} oldSize
 * @property {number|null} newSize
 * @property {boolean} binary
 * @property {string|null} mediaType
 * @property {DiffHunk[]|null} hunks
 * @property {string|null} oldText
 * @property {string|null} newText
 * @property {number} additions
 * @property {number} deletions
 */

/**
 * @typedef {CommitView & {files: FileDiff[]}} CommitDetail
 */

/**
 * @typedef {Object} CheckView
 * @property {string} ref
 * @property {string} workflow
 * @property {{uri: string, cid: string}} [subject]
 * @property {{uri: string, cid: string}} [check]
 * @property {string} sha
 * @property {'running'|'success'|'failure'|'error'} status
 * @property {string} startedAt
 * @property {string} [finishedAt]
 */

/**
 * @typedef {Object} ProfileView
 * @property {string} did
 * @property {string} handle
 * @property {string} displayName
 * @property {string} avatar
 */

/**
 * @typedef {Object} ClaimView
 * @property {string} name
 * @property {string} email
 * @property {string} label
 * @property {string} did
 * @property {string} avatar
 * @property {ProfileView|null} profile
 */

/**
 * The parameters a read of one repository takes. `cid` names the record
 * version the caller read: current, the answer caches immutably; stale, the
 * server answers RepoNotFound.
 * @typedef {Object} RepoParams
 * @property {string} repo - repository name, the record key
 * @property {string} [space] - the space holding it; needs the account session
 * @property {string} [cid]
 * @property {string} [from] - the account whose copy to read, for a branch in a collaborator's or a fork's copy
 */

/** @typedef {RepoParams & {ref?: string}} RefParams */
/** @typedef {RefParams & {path?: string}} PathParams */

/** @typedef {import('./pull-requests.js').Contributor} ContributorView */
/** @typedef {import('./pull-requests.js').PullRequest} PullRequestView */

const DESCRIBE = 'community.lexicon.service.describe';

/**
 * @param {Response} res
 * @returns {Promise<never>}
 */
async function refused(res) {
  /** @type {{error?: string, message?: string}} */
  let body = {};
  try {
    body = /** @type {{error?: string, message?: string}} */ (await res.json());
  } catch {
    // A body that is not JSON carries no name.
  }
  throw new XrpcError(
    res.status,
    body.error ?? (res.status === 501 ? 'MethodNotImplemented' : 'Unknown'),
    body.message ?? `request failed with status ${res.status}`,
  );
}

export class GitClient {
  /**
   * @param {Object} options
   * @param {string} options.service - the PDS base URL
   * @param {typeof fetch} [options.fetch] - what carries the request; the global by default
   * @param {Record<string, string>} [options.headers] - sent with every request, an Authorization say
   */
  constructor({ service, fetch: carrier, headers = {} }) {
    this.service = service.replace(/\/+$/, '');
    this.headers = headers;
    /** @type {typeof fetch} */
    this.fetch = carrier ?? ((input, init) => globalThis.fetch(input, init));
    /** @type {Promise<Set<string>>|null} */
    this.methods = null;
  }

  /**
   * The methods the server serves, asked once. A server that cannot answer
   * describe reads as serving none, which sends a caller to its fallback
   * rather than to a failure.
   * @returns {Promise<Set<string>>}
   */
  describe() {
    this.methods ??= (async () => {
      try {
        const res = await this.fetch(`${this.service}/xrpc/${DESCRIBE}`, {
          headers: this.headers,
        });
        if (!res.ok) return new Set();
        const { methods } =
          /** @type {{methods?: Array<{value?: unknown}>}} */ (
            await res.json()
          );
        return new Set(
          (methods ?? [])
            .map((entry) => entry.value)
            .filter((value) => typeof value === 'string'),
        );
      } catch {
        return new Set();
      }
    })();
    return this.methods;
  }

  /**
   * Whether the server answers a method, by its short name (`getTree`) or
   * its NSID.
   * @param {string} method
   */
  async serves(method) {
    const nsid = method.includes('.') ? method : `dev.pdsjs.git.${method}`;
    return (await this.describe()).has(nsid);
  }

  /**
   * Whether the server answers every read of the bundle chain.
   * @returns {Promise<boolean>}
   */
  async servesReads() {
    const served = await this.describe();
    return GIT_METHODS.every(
      (nsid) =>
        !READS.has(nsid.slice('dev.pdsjs.git.'.length)) || served.has(nsid),
    );
  }

  /**
   * @param {string} method - short name
   * @param {Record<string, string|number|undefined>} [params]
   * @returns {Promise<Response>}
   */
  async get(method, params = {}) {
    const query = new URLSearchParams();
    for (const [key, value] of Object.entries(params)) {
      if (value !== undefined && value !== '') query.set(key, String(value));
    }
    const suffix = query.size ? `?${query}` : '';
    const res = await this.fetch(
      `${this.service}/xrpc/dev.pdsjs.git.${method}${suffix}`,
      { headers: this.headers },
    );
    if (!res.ok) await refused(res);
    return res;
  }

  /**
   * @param {string} method - short name
   * @param {Record<string, string|number|undefined>} [params]
   * @returns {Promise<any>}
   */
  async query(method, params) {
    return (await this.get(method, params)).json();
  }

  /**
   * @param {string} method - short name
   * @param {unknown} body
   * @returns {Promise<any>}
   */
  async procedure(method, body) {
    const res = await this.fetch(
      `${this.service}/xrpc/dev.pdsjs.git.${method}`,
      {
        method: 'POST',
        headers: { ...this.headers, 'Content-Type': 'application/json' },
        body: JSON.stringify(body ?? {}),
      },
    );
    if (!res.ok) await refused(res);
    return res.json();
  }

  /**
   * @param {{summary?: boolean}} [params] - summary asks the server for each
   *   public repository's open pull request and issue counts and its recent
   *   commit dates
   * @returns {Promise<{httpEnabled: boolean, repos: RepoView[]}>}
   */
  listRepos(params) {
    return this.query(
      'listRepos',
      params?.summary ? { summary: 'true' } : undefined,
    );
  }

  /** @returns {Promise<{owner: ProfileView|null, claims: ClaimView[]}>} */
  getAuthors() {
    return this.query('getAuthors');
  }

  /**
   * @param {{repo: string, space?: string}} params
   * @returns {Promise<RepoView>}
   */
  getRepo(params) {
    return this.query('getRepo', params);
  }

  /**
   * @param {PathParams} params
   * @returns {Promise<TreeView>}
   */
  getTree(params) {
    return this.query('getTree', params);
  }

  /**
   * @param {RefParams} params
   * @returns {Promise<{ref: string, files: Array<{path: string, size: number}>, truncated: boolean}>}
   */
  listFiles(params) {
    return this.query('listFiles', params);
  }

  /**
   * @param {PathParams & {path: string}} params
   * @returns {Promise<FileView>}
   */
  getFile(params) {
    return this.query('getFile', params);
  }

  /**
   * The file's bytes, as a Response whose Content-Type names an image's
   * media type, text/plain for text, and an octet stream otherwise.
   * @param {PathParams & {path: string}} params
   * @returns {Promise<Response>}
   */
  getRaw(params) {
    return this.get('getRaw', params);
  }

  /**
   * @param {RefParams & {limit?: number, range?: string, path?: string}} params - `range` as base..tip lists the commits across it in place of a ref; `path` narrows the history to the commits that changed it
   * @returns {Promise<{ref: string, commits: CommitView[], truncated: boolean}>}
   */
  listCommits(params) {
    return this.query('listCommits', params);
  }

  /**
   * @param {RepoParams & {sha: string}} params
   * @returns {Promise<CommitDetail>}
   */
  getCommit(params) {
    return this.query('getCommit', params);
  }

  /**
   * @param {PathParams} params
   * @returns {Promise<{commit: CommitView|null}>}
   */
  getLastCommit(params) {
    return this.query('getLastCommit', params);
  }

  /**
   * @param {{repo: string, space?: string}} params
   * @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, checks: CheckView[]}>}
   */
  listChecks(params) {
    return this.query('listChecks', params);
  }

  /**
   * @param {{repo: string, space?: string}} params
   * @returns {Promise<{runner: string|null, service?: string, unreachable?: boolean, runs: Array<Record<string, unknown> & {rkey?: string, sha: string, status: string, startedAt: string}>}>}
   */
  listCheckRuns(params) {
    return this.query('listCheckRuns', params);
  }

  /**
   * @param {{repo: string, space?: string}} params
   * @returns {Promise<{open: PullRequestView[], merged: PullRequestView[], collaborators: ContributorView[], reviews: Array<[string, Array<Record<string, unknown>>]>, checks: Array<[string, Array<Record<string, unknown>>]>, configured: boolean}>}
   */
  listPulls(params) {
    return this.query('listPulls', params);
  }

  /**
   * @param {{repo: string, space?: string}} params
   * @returns {Promise<{statements: Array<Record<string, unknown> & {uri: string, cid: string, by: ContributorView, indexed?: boolean}>}>}
   */
  listIssues(params) {
    return this.query('listIssues', params);
  }

  /**
   * @param {{name: string, description?: string, space?: string, type?: string, skey?: string}} body
   * @returns {Promise<{name: string, space: string|null}>}
   */
  createRepo(body) {
    return this.procedure('createRepo', body);
  }

  /**
   * @param {{name: string, space?: string, description?: string, defaultBranch?: string, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}} body
   * @returns {Promise<{name: string, space: string|null, protectedBranches?: string[], runner?: string|null, packages?: Array<{registry: string, name: string}>}>}
   */
  updateSettings(body) {
    return this.procedure('updateSettings', body);
  }

  /**
   * @param {{name: string, ref: string}} body
   * @returns {Promise<{runner: string, ref: string}>}
   */
  requestCheck(body) {
    return this.procedure('requestCheck', body);
  }

  /**
   * @param {{name: string, did: string, ref: string, mode?: 'merge'|'squash',
   *   handle?: string, subject?: string, body?: string}} body
   * @returns {Promise<{sha: string, ref: string, mode: string, objects: number}>}
   */
  mergePull(body) {
    return this.procedure('mergePull', body);
  }
}

/** The methods that read the bundle chain, registered only with a browser. */
const READS = new Set([
  'getTree',
  'listFiles',
  'getFile',
  'getRaw',
  'listCommits',
  'getCommit',
  'getLastCommit',
]);