chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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;
}