// @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} files * @property {string|null} fallback - path served with 200 for unmatched paths * @property {string|null} createdAt */ const CACHE_LIMIT = 32; /** @type {Map} */ 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} 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} 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} */ 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(); }