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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
270 lines · 9.2 KB Raw History
// @pdsjs/sites - deploy planning
//
// The pure half of the CLI: given the local files (already read) and the
// currently deployed record, decide what to upload, what to reuse, and what
// the new record looks like. Unchanged files keep their existing blob refs
// verbatim — same bytes, same CID, no upload — and stay GC-referenced
// because the new record still carries them.

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

/** Upload Content-Types by extension. What the client sends is what the
 * blob stores (the server's sniffer only recognizes media formats), so this
 * is where site assets get their real types. */
const UPLOAD_TYPES = new Map([
  ['html', 'text/html; charset=utf-8'],
  ['css', 'text/css; charset=utf-8'],
  ['js', 'text/javascript; charset=utf-8'],
  ['mjs', 'text/javascript; charset=utf-8'],
  ['json', 'application/json'],
  ['svg', 'image/svg+xml'],
  ['txt', 'text/plain; charset=utf-8'],
  ['xml', 'application/xml'],
  ['png', 'image/png'],
  ['jpg', 'image/jpeg'],
  ['jpeg', 'image/jpeg'],
  ['gif', 'image/gif'],
  ['webp', 'image/webp'],
  ['avif', 'image/avif'],
  ['ico', 'image/x-icon'],
  ['pdf', 'application/pdf'],
  ['wasm', 'application/wasm'],
  ['map', 'application/json'],
  ['webmanifest', 'application/manifest+json'],
  ['woff', 'font/woff'],
  ['woff2', 'font/woff2'],
  ['ttf', 'font/ttf'],
  ['otf', 'font/otf'],
  ['mp4', 'video/mp4'],
  ['webm', 'video/webm'],
  ['mp3', 'audio/mpeg'],
]);

export const DEFAULT_BLOB_LIMIT = 5 * 1024 * 1024;

/** putRecord bodies are JSON and the server caps them at 1MB; leave slack
 * for the request envelope around the record. */
export const RECORD_JSON_LIMIT = 900_000;

/**
 * The Content-Type to upload a file with.
 * @param {string} path
 * @returns {string}
 */
export function uploadContentType(path) {
  const dot = path.lastIndexOf('.');
  const ext = dot === -1 ? '' : path.slice(dot + 1).toLowerCase();
  return UPLOAD_TYPES.get(ext) || 'application/octet-stream';
}

/**
 * A per-file Cache-Control rule: a glob over manifest paths and the header
 * value matched files get. A rule with no glob matches every file.
 * @typedef {{glob?: string, value: string}} CacheRule
 */

/**
 * Compile a path glob to an anchored RegExp. `**` crosses slashes, `*` stays
 * within a segment, `?` is one character.
 * @param {string} glob
 * @returns {RegExp}
 */
export function compileGlob(glob) {
  let pattern = '';
  for (let i = 0; i < glob.length; i++) {
    const char = glob[i];
    if (char === '*') {
      if (glob[i + 1] === '*') {
        pattern += '.*';
        i++;
      } else {
        pattern += '[^/]*';
      }
    } else if (char === '?') {
      pattern += '[^/]';
    } else {
      pattern += char.replace(/[.+^${}()|[\]\\]/, '\\$&');
    }
  }
  return new RegExp(`^${pattern}$`);
}

/**
 * The Cache-Control a path gets under the given rules: the last matching
 * rule wins, so broad rules come first and overrides follow.
 * @param {string} path
 * @param {CacheRule[]} rules
 * @returns {string|undefined}
 */
export function cacheControlFor(path, rules) {
  let value;
  for (const rule of rules) {
    if (!rule.glob || compileGlob(rule.glob).test(path)) value = rule.value;
  }
  return value;
}

/**
 * A blob ref in its JSON (XRPC) shape.
 * @typedef {{$type: 'blob', ref: {$link: string}, mimeType: string, size: number}} JsonBlobRef
 */

/**
 * @typedef {Object} DeployPlan
 * @property {Array<{path: string, cid: string, contentType: string, bytes: Uint8Array}>} uploads
 * @property {string[]} reused - paths kept from the current deploy
 * @property {string[]} removed - paths in the current deploy but not local
 * @property {Record<string, unknown>} record - the new dev.pdsjs.site.deploy value, with upload placeholders filled by finishRecord
 */

/**
 * Plan a deploy.
 *
 * @param {Object} input
 * @param {string} input.site - site name (record key)
 * @param {Array<{path: string, bytes: Uint8Array}>} input.files - local files, forward-slash relative paths
 * @param {{value?: {files?: Array<{path?: string, blob?: JsonBlobRef, contentType?: string, cacheControl?: string}>, base?: unknown}}|null} input.existing - the currently deployed record (JSON shape), if any
 * @param {string} [input.fallback] - SPA fallback path
 * @param {CacheRule[]} [input.cacheRules] - per-file Cache-Control overrides; unmatched files keep what the current record says
 * @param {number} [input.blobLimit] - per-file byte cap (server upload limit)
 * @param {string} [input.now] - createdAt timestamp
 * @returns {Promise<DeployPlan>}
 * @throws {Error} on invalid site names, oversized files, or an oversized record
 */
export async function planDeploy({
  site,
  files,
  existing,
  fallback,
  cacheRules = [],
  blobLimit = DEFAULT_BLOB_LIMIT,
  now = new Date().toISOString(),
}) {
  if (!isValidSiteName(site)) {
    throw new Error(
      `"${site}" cannot name a site: names are lowercase DNS labels (a-z, 0-9, interior hyphens, max 63 chars) because they become subdomains.`,
    );
  }

  const oversized = files.filter((file) => file.bytes.length > blobLimit);
  if (oversized.length > 0) {
    const listing = oversized
      .map((file) => `  ${file.path} (${file.bytes.length} bytes)`)
      .join('\n');
    throw new Error(
      `These files exceed the blob upload limit of ${blobLimit} bytes:\n${listing}\nRaise PDS_BLOB_UPLOAD_LIMIT on the server, or host large media elsewhere.`,
    );
  }

  const duplicates = new Set();
  const seen = new Set();
  for (const file of files) {
    if (seen.has(file.path)) duplicates.add(file.path);
    seen.add(file.path);
  }
  if (duplicates.size > 0) {
    throw new Error(`Duplicate paths in input: ${[...duplicates].join(', ')}`);
  }

  /** @type {Map<string, {blob: JsonBlobRef, contentType?: string, cacheControl?: string}>} */
  const current = new Map();
  for (const entry of existing?.value?.files || []) {
    if (typeof entry?.path === 'string' && entry.blob?.ref?.$link) {
      current.set(entry.path.replace(/^\/+/, ''), {
        blob: entry.blob,
        contentType: entry.contentType,
        cacheControl: entry.cacheControl,
      });
    }
  }

  /** @type {DeployPlan['uploads']} */
  const uploads = [];
  /** @type {string[]} */
  const reused = [];
  const recordFiles = [];

  for (const file of files) {
    const path = file.path.replace(/^\/+/, '');
    const cid = cidToString(await createBlobCid(file.bytes));
    const contentType = uploadContentType(path);
    const existingEntry = current.get(path);
    // A rule set on this deploy wins; a file no rule matches keeps whatever
    // the current record says, so partial rules do not silently reset paths.
    const cacheControl =
      cacheControlFor(path, cacheRules) ?? existingEntry?.cacheControl;
    if (existingEntry && existingEntry.blob.ref.$link === cid) {
      reused.push(path);
      recordFiles.push({
        path,
        blob: existingEntry.blob,
        contentType,
        ...(cacheControl ? { cacheControl } : {}),
      });
    } else {
      uploads.push({ path, cid, contentType, bytes: file.bytes });
      recordFiles.push({
        path,
        // Filled in from the uploadBlob response by finishRecord; carrying
        // the locally computed CID here lets the CLI cross-check the server.
        blob: {
          $type: 'blob',
          ref: { $link: cid },
          mimeType: contentType,
          size: file.bytes.length,
        },
        contentType,
        ...(cacheControl ? { cacheControl } : {}),
      });
    }
  }

  const removed = [...current.keys()].filter((path) => !seen.has(path));

  /** @type {Record<string, unknown>} */
  const record = {
    $type: SITE_COLLECTION,
    files: recordFiles,
    createdAt: now,
  };
  if (fallback) {
    const normalized = fallback.replace(/^\/+/, '');
    if (!recordFiles.some((entry) => entry.path === normalized)) {
      throw new Error(`Fallback "${fallback}" is not among the site's files.`);
    }
    record.fallback = normalized;
  }
  // A base declared on the current record survives the deploy untouched;
  // managing base records is manual for now.
  if (existing?.value?.base) record.base = existing.value.base;

  const encoded = new TextEncoder().encode(JSON.stringify(record)).length;
  if (encoded > RECORD_JSON_LIMIT) {
    throw new Error(
      `The site manifest is ${encoded} bytes as JSON, over the ${RECORD_JSON_LIMIT}-byte record ceiling (${recordFiles.length} files). Split assets into a base record (the "base" field) or host fewer files per site.`,
    );
  }

  return { uploads, reused, removed, record };
}

/**
 * Cross-check an uploadBlob response against the plan and return the ref to
 * embed. The server computing a different CID than the client means the
 * bytes were mangled in transit; failing beats deploying a corrupt site.
 * @param {{path: string, cid: string}} upload - the planned upload
 * @param {JsonBlobRef} returned - the uploadBlob response's blob
 * @returns {JsonBlobRef}
 * @throws {Error} on CID mismatch
 */
export function checkUploadedRef(upload, returned) {
  const serverCid = returned?.ref?.$link;
  if (serverCid !== upload.cid) {
    throw new Error(
      `Upload of ${upload.path} came back as ${serverCid}, expected ${upload.cid}. Not deploying.`,
    );
  }
  return returned;
}