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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
201 lines · 6.5 KB Raw History
// @pdsjs/sites - site manifest loading
//
// A site is one dev.pdsjs.site.deploy record; this module turns it into a
// path → file map ready to serve. The record itself is re-read on every
// request — it is storage-local and it is the only correct redeploy signal —
// but the decoded, merged map is cached keyed by the record CIDs involved,
// so a redeploy (new CID) misses and everything else hits without TTLs or
// an invalidation protocol.

import { cborDecode, cidToString } from '@pdsjs/core/repo';
import { SITE_COLLECTION } from './lexicons.js';

/**
 * @typedef {Object} SiteFile
 * @property {string} cid - blob CID string
 * @property {string} mimeType - mime type stored with the blob
 * @property {number} size - blob size in bytes
 * @property {string} [contentType] - manifest override for Content-Type
 * @property {string} [cacheControl] - manifest override for Cache-Control
 * @property {boolean} [originTemplate] - fill __PDSJS_ORIGIN__ with the serving origin
 */

/**
 * @typedef {Object} SiteManifest
 * @property {string} cid - the deploy record's CID
 * @property {Map<string, SiteFile>} files
 * @property {string|null} fallback - path served with 200 for unmatched paths
 * @property {string|null} createdAt
 */

const CACHE_LIMIT = 32;

/** @type {Map<string, SiteManifest>} */
const cache = new Map();

/**
 * Decoded record value for a dev.pdsjs.site.deploy record.
 * @typedef {Object} DeployRecordValue
 * @property {Array<{path?: unknown, blob?: {ref?: Uint8Array|{$link?: unknown}, mimeType?: string, size?: number}, contentType?: unknown, cacheControl?: unknown}>} [files]
 * @property {unknown} [fallback]
 * @property {{uri?: unknown}} [base]
 * @property {unknown} [createdAt]
 */

/**
 * Read and decode one deploy record.
 * @param {import('@pdsjs/core/ports').ActorStoragePort} actorStorage
 * @param {string} did
 * @param {string} name - site name (record key)
 * @returns {Promise<{cid: string, value: DeployRecordValue}|null>}
 */
async function readDeployRecord(actorStorage, did, name) {
  const record = await actorStorage.getRecord(
    `at://${did}/${SITE_COLLECTION}/${name}`,
  );
  if (!record) return null;
  const bytes =
    record.value instanceof Uint8Array
      ? record.value
      : new Uint8Array(record.value);
  return {
    cid: record.cid,
    value: /** @type {DeployRecordValue} */ (cborDecode(bytes)),
  };
}

/**
 * Fold one record's files into the map. Later calls win on path conflicts,
 * so the base record is folded first and the site record second.
 * @param {Map<string, SiteFile>} files
 * @param {DeployRecordValue} value
 */
function addFiles(files, value) {
  if (!Array.isArray(value.files)) return;
  for (const entry of value.files) {
    if (typeof entry?.path !== 'string' || entry.path === '') continue;
    // Same two shapes findBlobRefs accepts: a ref written over XRPC decodes
    // as `{$link}`, one from an imported CAR as raw CID bytes.
    const ref = entry.blob?.ref;
    /** @type {string|null} */
    let cid = null;
    if (ref instanceof Uint8Array) cid = cidToString(ref);
    else if (
      typeof (/** @type {{$link?: unknown}} */ (ref)?.$link) === 'string'
    ) {
      cid = /** @type {{$link: string}} */ (ref).$link;
    }
    if (!cid) continue;
    const path = entry.path.replace(/^\/+/, '');
    /** @type {SiteFile} */
    const file = {
      cid,
      mimeType:
        typeof entry.blob?.mimeType === 'string'
          ? entry.blob.mimeType
          : 'application/octet-stream',
      size: typeof entry.blob?.size === 'number' ? entry.blob.size : 0,
    };
    if (typeof entry.contentType === 'string') {
      file.contentType = entry.contentType;
    }
    if (typeof entry.cacheControl === 'string') {
      file.cacheControl = entry.cacheControl;
    }
    if (
      /** @type {{originTemplate?: unknown}} */ (entry).originTemplate === true
    ) {
      file.originTemplate = true;
    }
    files.set(path, file);
  }
}

/**
 * Extract the record key from a base strongRef, requiring it to name a
 * dev.pdsjs.site.deploy record in the same repo.
 * @param {DeployRecordValue} value
 * @param {string} did
 * @returns {string|null}
 */
function baseName(value, did) {
  const uri = value.base?.uri;
  if (typeof uri !== 'string') return null;
  const prefix = `at://${did}/${SITE_COLLECTION}/`;
  if (!uri.startsWith(prefix)) return null;
  const rkey = uri.slice(prefix.length);
  return rkey && !rkey.includes('/') ? rkey : null;
}

/**
 * Load a site's manifest as a servable path map.
 *
 * The base record (when declared) is read live rather than at its pinned
 * CID — both records live in the same repo under the same owner, and the
 * cache key carries both CIDs so either record changing invalidates the
 * merged map.
 *
 * @param {import('@pdsjs/core/ports').ActorStoragePort} actorStorage
 * @param {string} did
 * @param {string} name - site name (record key)
 * @returns {Promise<SiteManifest|null>} null when the site does not exist
 * @throws {Error} when the base chain is deeper than one hop
 */
export async function loadSite(actorStorage, did, name) {
  const record = await readDeployRecord(actorStorage, did, name);
  if (!record) return null;

  const base = baseName(record.value, did);
  const baseRecord =
    base && base !== name
      ? await readDeployRecord(actorStorage, did, base)
      : null;

  const key = baseRecord ? `${record.cid}+${baseRecord.cid}` : record.cid;
  const cached = cache.get(key);
  if (cached) {
    // Refresh recency so hot sites survive eviction
    cache.delete(key);
    cache.set(key, cached);
    return cached;
  }

  if (baseRecord && baseName(baseRecord.value, did)) {
    throw new Error(
      `site "${name}": base record "${base}" declares its own base; one hop only`,
    );
  }

  /** @type {Map<string, SiteFile>} */
  const files = new Map();
  if (baseRecord) addFiles(files, baseRecord.value);
  addFiles(files, record.value);

  const fallbackPath =
    typeof record.value.fallback === 'string'
      ? record.value.fallback.replace(/^\/+/, '')
      : null;

  /** @type {SiteManifest} */
  const manifest = {
    cid: record.cid,
    files,
    fallback: fallbackPath && files.has(fallbackPath) ? fallbackPath : null,
    createdAt:
      typeof record.value.createdAt === 'string'
        ? record.value.createdAt
        : null,
  };

  cache.set(key, manifest);
  if (cache.size > CACHE_LIMIT) {
    const oldest = cache.keys().next().value;
    if (oldest !== undefined) cache.delete(oldest);
  }
  return manifest;
}

/** Empty the manifest cache. Tests only. */
export function clearManifestCache() {
  cache.clear();
}