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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
111 lines · 3.5 KB Raw History
/**
 * Getting a working tree for a run. The PDS serves these repositories over
 * read-only smart HTTP, so this is a plain clone with a stock git binary and
 * needs neither the remote helper nor credentials.
 */

import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

/**
 * @param {string[]} args
 * @param {(chunk: string) => void} onLog
 * @param {Record<string, string>} [env] - added to the environment
 * @returns {Promise<void>}
 */
function git(args, onLog, env) {
  return new Promise((resolve, reject) => {
    const child = spawn('git', args, {
      stdio: ['ignore', 'pipe', 'pipe'],
      env: env ? { ...process.env, ...env } : process.env,
    });
    let stderr = '';
    child.stdout.setEncoding('utf8');
    child.stderr.setEncoding('utf8');
    child.stdout.on('data', onLog);
    child.stderr.on('data', (chunk) => {
      stderr += chunk;
      onLog(chunk);
    });
    child.on('error', reject);
    child.on('close', (code) => {
      if (code === 0) resolve();
      else reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`));
    });
  });
}

/**
 * The clone URL the PDS serves this repository at.
 * @param {string} service - PDS base URL
 * @param {string} did
 * @param {string} repoName
 * @returns {string}
 */
export function cloneUrl(service, did, repoName) {
  return `${service.replace(/\/+$/, '')}/git/${did}/${encodeURIComponent(repoName)}`;
}

/**
 * The remote helper's URL for the same repository.
 * @param {string} did
 * @param {string} repoName
 * @returns {string}
 */
export function helperUrl(did, repoName) {
  return `atproto://${did}/${encodeURIComponent(repoName)}`;
}

/**
 * Clone the repository and detach at one commit. The caller owns the
 * directory and passes it to `discardCheckout` when the run finishes.
 *
 * Two ways in. Smart HTTP needs no helper on the PATH, so it goes first. It
 * serves a repository by merging the bundle chain's packfiles, which git
 * rejects when a chain repeats an object, so a chain long enough to repeat
 * one falls back to the helper. The helper unbundles each bundle on its own
 * and does not care.
 * @param {Object} options
 * @param {string} options.service
 * @param {string} options.did
 * @param {string} options.repoName
 * @param {string} options.sha
 * @param {(chunk: string) => void} options.onLog
 * @returns {Promise<string>} the working tree path
 */
export async function checkout(options) {
  const { service, did, repoName, sha, onLog } = options;
  const dir = await mkdtemp(join(tmpdir(), 'pdsjs-git-ci-'));
  try {
    try {
      await git(
        ['clone', '--quiet', cloneUrl(service, did, repoName), dir],
        onLog,
      );
    } catch (httpErr) {
      onLog(
        `\n[smart HTTP clone failed: ${httpErr instanceof Error ? httpErr.message : httpErr}]\n[retrying with git-remote-atproto]\n`,
      );
      await rm(dir, { recursive: true, force: true });
      await git(['clone', '--quiet', helperUrl(did, repoName), dir], onLog, {
        // The helper resolves the authority through PLC without this.
        ATPROTO_GIT_SERVICE: service,
      });
    }
    await git(['-C', dir, 'checkout', '--quiet', '--detach', sha], onLog);
    return dir;
  } catch (err) {
    await discardCheckout(dir);
    throw err;
  }
}

/**
 * @param {string} dir
 * @returns {Promise<void>}
 */
export async function discardCheckout(dir) {
  await rm(dir, { recursive: true, force: true });
}