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