chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* Typed shape of the dev.pdsjs.git.repo record plus a runtime parser for
* values coming back from getRecord (which are untyped JSON).
*/
/**
* @typedef {Object} BlobObject
* @property {string} [$type]
* @property {{$link: string}} ref
* @property {string} mimeType
* @property {number} size
*/
/**
* @typedef {Object} RefEntry
* @property {string} name
* @property {string} sha
*/
/**
* @typedef {Object} BundleEntry
* @property {string[]} heads
* @property {BlobObject[]} parts
* @property {BlobObject} [index] - the bundle's object index
* (pack-index.js), for readers that fetch single objects with ranged
* blob reads
* @property {BlobObject[]} [indexParts] - the same index, split across blobs
* for a repository whose index is larger than one blob. Read
* indexPartsOf, which gives both forms as one list.
*/
/**
* @typedef {Object} GitRepoRecord
* @property {string} [$type]
* @property {string} name
* @property {string} [description]
* @property {string} [defaultBranch]
* @property {string} [upstream] - the repository record this copy was taken
* from, where the copy is somebody else's repository
* @property {RefEntry[]} refs
* @property {BundleEntry[]} bundles
* @property {string} createdAt
* @property {string} [updatedAt]
*/
const SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
/**
* A repository name is an address: it stands in a remote URL, a clone URL and
* a page's path, so it takes the charset a record key takes.
* @param {string} name
* @returns {boolean}
*/
export function isValidRepoName(name) {
return /^[A-Za-z0-9._~-]{1,512}$/.test(name) && name !== '.' && name !== '..';
}
/** The record key an AT-URI ends with. */
export const rkeyOf = (/** @type {string} */ uri) => uri.split('/').pop() ?? '';
const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
const clockId = Math.floor(Math.random() * 1024);
let lastTid = 0;
/**
* A record key for a new repository: an atproto TID, so keys sort by when
* they were made and no key is a name.
*
* The remote helper ships as a CLI with no dependency on the server, so this
* mints its own rather than reaching for core's.
* @returns {string}
*/
export function createRepoKey() {
let micros = Date.now() * 1000;
if (micros <= lastTid) micros = lastTid + 1;
lastTid = micros;
let tid = '';
let left = micros;
for (let i = 0; i < 11; i += 1) {
tid = TID_CHARS[left & 31] + tid;
left = Math.floor(left / 32);
}
return tid + TID_CHARS[(clockId >> 5) & 31] + TID_CHARS[clockId & 31];
}
/**
* What a repository record calls itself. The name is a required field, so the
* key is a fallback for a record written against no lexicon at all.
* @param {unknown} value
* @param {string} rkey
* @returns {string}
*/
export function repoNameOf(value, rkey) {
const named = /** @type {{name?: unknown}} */ (value ?? {}).name;
return typeof named === 'string' && named ? named : rkey;
}
/**
* @param {unknown} value
* @returns {value is BlobObject}
*/
function isBlobObject(value) {
if (typeof value !== 'object' || value === null) return false;
const blob = /** @type {Record<string, unknown>} */ (value);
const ref = blob.ref;
if (typeof ref !== 'object' || ref === null) return false;
return (
typeof (/** @type {Record<string, unknown>} */ (ref).$link) === 'string' &&
typeof blob.mimeType === 'string' &&
typeof blob.size === 'number'
);
}
/**
* Validate an untyped getRecord value into a GitRepoRecord.
* @param {unknown} value
* @returns {GitRepoRecord}
*/
export function parseRepoRecord(value) {
if (typeof value !== 'object' || value === null) {
throw new Error('git repo record is not an object');
}
const rec = /** @type {Record<string, unknown>} */ (value);
if (typeof rec.name !== 'string' || typeof rec.createdAt !== 'string') {
throw new Error('git repo record is missing name or createdAt');
}
if (!Array.isArray(rec.refs) || !Array.isArray(rec.bundles)) {
throw new Error('git repo record is missing refs or bundles');
}
/** @type {RefEntry[]} */
const refs = [];
for (const entry of rec.refs) {
const ref = /** @type {Record<string, unknown>} */ (entry);
if (
typeof ref !== 'object' ||
ref === null ||
typeof ref.name !== 'string' ||
typeof ref.sha !== 'string' ||
!SHA_RE.test(ref.sha)
) {
throw new Error('git repo record has a malformed ref entry');
}
refs.push({ name: ref.name, sha: ref.sha });
}
/** @type {BundleEntry[]} */
const bundles = [];
for (const entry of rec.bundles) {
const bundle = /** @type {Record<string, unknown>} */ (entry);
if (
typeof bundle !== 'object' ||
bundle === null ||
!Array.isArray(bundle.heads) ||
!Array.isArray(bundle.parts) ||
!bundle.heads.every((h) => typeof h === 'string') ||
!bundle.parts.every(isBlobObject)
) {
throw new Error('git repo record has a malformed bundle entry');
}
// A malformed index reads as absent, which downgrades the reader to
// whole-chain download; the parts stay authoritative either way.
const index = isBlobObject(bundle.index)
? /** @type {BlobObject} */ (bundle.index)
: undefined;
const indexParts =
Array.isArray(bundle.indexParts) && bundle.indexParts.every(isBlobObject)
? /** @type {BlobObject[]} */ (bundle.indexParts)
: undefined;
bundles.push({
heads: /** @type {string[]} */ (bundle.heads),
parts: /** @type {BlobObject[]} */ (bundle.parts),
...(index ? { index } : {}),
...(indexParts ? { indexParts } : {}),
});
}
return {
name: rec.name,
description:
typeof rec.description === 'string' ? rec.description : undefined,
defaultBranch:
typeof rec.defaultBranch === 'string' ? rec.defaultBranch : undefined,
upstream: typeof rec.upstream === 'string' ? rec.upstream : undefined,
refs,
bundles,
createdAt: rec.createdAt,
updatedAt: typeof rec.updatedAt === 'string' ? rec.updatedAt : undefined,
};
}
/**
* A bundle's index blobs, in order, whichever form the record carries.
* Concatenating them recovers the index file. An empty list means the
* bundle has no index, and its reader downloads the whole chain.
* @param {BundleEntry} bundle
* @returns {BlobObject[]}
*/
export function indexPartsOf(bundle) {
if (bundle.indexParts) return bundle.indexParts;
return bundle.index ? [bundle.index] : [];
}
/**
* The chain index where a compaction merge begins. The suffix from that
* index is the shortest tail whose merge keeps the sizes geometric: every
* kept bundle is at least `factor` times the total size after it. The last
* index alone means nothing merges. A factor of 0 selects the last index.
* @param {number[]} sizes - bundle byte sizes, oldest first
* @param {number} factor
* @returns {number}
*/
export function compactionStart(sizes, factor) {
let start = sizes.length - 1;
let sum = sizes[start] ?? 0;
for (let i = start - 1; i >= 0; i--) {
if (sizes[i] >= factor * sum) break;
sum += sizes[i];
start = i;
}
return start;
}
/**
* Split bundle bytes into blob-sized chunks.
* @param {Uint8Array} bytes
* @param {number} chunkSize
* @returns {Uint8Array[]}
*/
export function splitIntoChunks(bytes, chunkSize) {
if (chunkSize <= 0) throw new Error('chunk size must be positive');
/** @type {Uint8Array[]} */
const chunks = [];
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
chunks.push(bytes.subarray(offset, offset + chunkSize));
}
return chunks.length > 0 ? chunks : [bytes];
}
/**
* Reassemble a bundle from its downloaded chunks.
* @param {Uint8Array[]} chunks
* @returns {Uint8Array}
*/
export function concatChunks(chunks) {
const total = chunks.reduce((sum, c) => sum + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
out.set(chunk, offset);
offset += chunk.length;
}
return out;
}