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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
105 lines · 3.4 KB Raw History
/**
 * Writing a packfile and a bundle, for a caller that has objects in hand and
 * no git to shell out to.
 *
 * The node helper takes the pack git hands it over the wire and never builds
 * one. A server merging a pull request has no such pack: it holds the commit
 * it just made and the objects the branch it is merging brought with it, and
 * a push is a bundle plus a record write.
 *
 * Every object is stored whole. A delta would be smaller, and finding a good
 * base is the whole cost of packing; a merge writes one commit and the trees
 * under it, so the saving is not worth the code that finds it. A repack on the
 * server side is what compacts a chain.
 *
 * Workers-clean: pako and a pure-JS SHA-1, no node imports.
 */

import pako from 'pako';
import { Sha1 } from './sha1.js';

/** Pack object type codes, in the order the format numbers them. */
const TYPE_CODES = { commit: 1, tree: 2, blob: 3, tag: 4 };

const encoder = new TextEncoder();

/**
 * One object's header: its type and its inflated size, seven bits at a time.
 * The first byte carries four size bits after the type, and the top bit says
 * another byte follows.
 * @param {number} type
 * @param {number} size
 * @returns {number[]}
 */
function objectHeader(type, size) {
  const bytes = [(type << 4) | (size & 0x0f)];
  let rest = Math.floor(size / 16);
  while (rest > 0) {
    bytes[bytes.length - 1] |= 0x80;
    bytes.push(rest & 0x7f);
    rest >>>= 7;
  }
  return bytes;
}

/**
 * A packfile holding every object given, none of them deltified.
 * @param {Array<{type: string, data: Uint8Array}>} objects
 * @returns {Uint8Array}
 */
export function writePack(objects) {
  /** @type {Uint8Array[]} */
  const parts = [];
  const head = new Uint8Array(12);
  head.set(encoder.encode('PACK'), 0);
  new DataView(head.buffer).setUint32(4, 2);
  new DataView(head.buffer).setUint32(8, objects.length);
  parts.push(head);

  for (const object of objects) {
    const code =
      TYPE_CODES[/** @type {keyof typeof TYPE_CODES} */ (object.type)];
    if (!code) throw new Error(`cannot pack a ${object.type}`);
    parts.push(Uint8Array.from(objectHeader(code, object.data.length)));
    parts.push(pako.deflate(object.data));
  }

  const body = concat(parts);
  const digest = new Sha1().update(body).digest();
  const pack = new Uint8Array(body.length + 20);
  pack.set(body, 0);
  pack.set(digest, body.length);
  return pack;
}

/**
 * A v2 bundle: the refs it carries, the objects it assumes the reader already
 * has, and the pack.
 *
 * A prerequisite is what makes a bundle incremental. The reader must hold that
 * object already, which for a chain means an earlier bundle carried it.
 * @param {Object} bundle
 * @param {Array<{sha: string, name: string}>} bundle.refs
 * @param {string[]} [bundle.prerequisites]
 * @param {Uint8Array} bundle.pack
 * @returns {Uint8Array}
 */
export function writeBundle({ refs, prerequisites = [], pack }) {
  const lines = ['# v2 git bundle'];
  for (const sha of prerequisites) lines.push(`-${sha}`);
  for (const ref of refs) lines.push(`${ref.sha} ${ref.name}`);
  lines.push('', '');
  return concat([encoder.encode(lines.join('\n')), pack]);
}

/** @param {Uint8Array[]} parts */
function concat(parts) {
  const size = parts.reduce((total, part) => total + part.length, 0);
  const all = new Uint8Array(size);
  let at = 0;
  for (const part of parts) {
    all.set(part, at);
    at += part.length;
  }
  return all;
}