// @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} 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} * @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} */ 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} */ 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; }