Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import { AtpAgent } from '@atproto/api';
2import type { Record as WispFsRecord, Directory, Entry, File } from '@wisp/lexicons/types/place/wisp/fs';
3import type { Record as SubfsRecord } from '@wisp/lexicons/types/place/wisp/subfs';
4import type { Record as WispSettings } from '@wisp/lexicons/types/place/wisp/settings';
5import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs';
6import { writeFile, readFile, rename } from 'fs/promises';
7import { safeFetchJson, safeFetchBlob } from '@wisp/safe-fetch';
8import { CID } from 'multiformats';
9import { extractBlobCid } from '@wisp/atproto-utils';
10import { sanitizePath, collectFileCidsFromEntries, countFilesInDirectory } from '@wisp/fs-utils';
11import { shouldCompressMimeType } from '@wisp/atproto-utils/compression';
12import { MAX_BLOB_SIZE, MAX_FILE_COUNT, MAX_SITE_SIZE } from '@wisp/constants';
13
14// Re-export shared utilities for local usage and tests
15export { extractBlobCid, sanitizePath };
16
17const CACHE_DIR = process.env.CACHE_DIR || './cache/sites';
18const CACHE_TTL = 14 * 24 * 60 * 60 * 1000; // 14 days cache TTL
19
20interface CacheMetadata {
21 recordCid: string;
22 cachedAt: number;
23 did: string;
24 rkey: string;
25 // Map of file path to blob CID for incremental updates
26 fileCids?: Record<string, string>;
27 // Site settings (null = explicitly no settings, undefined = not yet checked)
28 settings?: WispSettings | null;
29}
30
31
32export async function resolveDid(identifier: string): Promise<string | null> {
33 try {
34 // If it's already a DID, return it
35 if (identifier.startsWith('did:')) {
36 return identifier;
37 }
38
39 // Otherwise, resolve the handle using agent's built-in method
40 const agent = new AtpAgent({ service: 'https://public.api.bsky.app' });
41 const response = await agent.resolveHandle({ handle: identifier });
42 return response.data.did;
43 } catch (err) {
44 console.error('Failed to resolve identifier', identifier, err);
45 return null;
46 }
47}
48
49export async function getPdsForDid(did: string): Promise<string | null> {
50 try {
51 let doc;
52
53 if (did.startsWith('did:plc:')) {
54 doc = await safeFetchJson(`https://plc.directory/${encodeURIComponent(did)}`);
55 } else if (did.startsWith('did:web:')) {
56 const didUrl = didWebToHttps(did);
57 doc = await safeFetchJson(didUrl);
58 } else {
59 console.error('Unsupported DID method', did);
60 return null;
61 }
62
63 const services = doc.service || [];
64 const pdsService = services.find((s: any) => s.id === '#atproto_pds');
65
66 return pdsService?.serviceEndpoint || null;
67 } catch (err) {
68 console.error('Failed to get PDS for DID', did, err);
69 return null;
70 }
71}
72
73function didWebToHttps(did: string): string {
74 const didParts = did.split(':');
75 if (didParts.length < 3 || didParts[0] !== 'did' || didParts[1] !== 'web') {
76 throw new Error('Invalid did:web format');
77 }
78
79 const domain = didParts[2];
80 const pathParts = didParts.slice(3);
81
82 if (pathParts.length === 0) {
83 return `https://${domain}/.well-known/did.json`;
84 } else {
85 const path = pathParts.join('/');
86 return `https://${domain}/${path}/did.json`;
87 }
88}
89
90export async function fetchSiteRecord(did: string, rkey: string): Promise<{ record: WispFsRecord; cid: string } | null> {
91 try {
92 const pdsEndpoint = await getPdsForDid(did);
93 if (!pdsEndpoint) return null;
94
95 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.fs&rkey=${encodeURIComponent(rkey)}`;
96 const data = await safeFetchJson(url);
97
98 return {
99 record: data.value as WispFsRecord,
100 cid: data.cid || ''
101 };
102 } catch (err) {
103 console.error('Failed to fetch site record', did, rkey, err);
104 return null;
105 }
106}
107
108export async function fetchSiteSettings(did: string, rkey: string): Promise<WispSettings | null> {
109 try {
110 const pdsEndpoint = await getPdsForDid(did);
111 if (!pdsEndpoint) return null;
112
113 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=place.wisp.settings&rkey=${encodeURIComponent(rkey)}`;
114 const data = await safeFetchJson(url);
115
116 return data.value as WispSettings;
117 } catch (err) {
118 // Settings are optional, so return null if not found
119 return null;
120 }
121}
122
123/**
124 * Calculate total size of all blobs in a directory tree from manifest metadata
125 */
126function calculateTotalBlobSize(directory: Directory): number {
127 let totalSize = 0;
128
129 function sumBlobSizes(entries: Entry[]) {
130 for (const entry of entries) {
131 const node = entry.node;
132
133 if ('type' in node && node.type === 'directory' && 'entries' in node) {
134 // Recursively sum subdirectories
135 sumBlobSizes(node.entries);
136 } else if ('type' in node && node.type === 'file' && 'blob' in node) {
137 // Add blob size from manifest
138 const fileNode = node as File;
139 const blobSize = (fileNode.blob as any)?.size || 0;
140 totalSize += blobSize;
141 }
142 }
143 }
144
145 sumBlobSizes(directory.entries);
146 return totalSize;
147}
148
149/**
150 * Extract all subfs URIs from a directory tree with their mount paths
151 */
152export function extractSubfsUris(directory: Directory, currentPath: string = ''): Array<{ uri: string; path: string }> {
153 const uris: Array<{ uri: string; path: string }> = [];
154
155 for (const entry of directory.entries) {
156 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
157
158 if ('type' in entry.node) {
159 if (entry.node.type === 'subfs') {
160 // Subfs node with subject URI
161 const subfsNode = entry.node as any;
162 if (subfsNode.subject) {
163 uris.push({ uri: subfsNode.subject, path: fullPath });
164 }
165 } else if (entry.node.type === 'directory') {
166 // Recursively search subdirectories
167 const subUris = extractSubfsUris(entry.node as Directory, fullPath);
168 uris.push(...subUris);
169 }
170 }
171 }
172
173 return uris;
174}
175
176/**
177 * Fetch a subfs record from the PDS
178 */
179async function fetchSubfsRecord(uri: string, pdsEndpoint: string): Promise<SubfsRecord | null> {
180 try {
181 // Parse URI: at://did/collection/rkey
182 const parts = uri.replace('at://', '').split('/');
183 if (parts.length < 3) {
184 console.error('Invalid subfs URI:', uri);
185 return null;
186 }
187
188 const did = parts[0] || '';
189 const collection = parts[1] || '';
190 const rkey = parts[2] || '';
191
192 // Fetch the record from PDS
193 const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`;
194 const response = await safeFetchJson(url);
195
196 if (!response || !response.value) {
197 console.error('Subfs record not found:', uri);
198 return null;
199 }
200
201 return response.value as SubfsRecord;
202 } catch (err) {
203 console.error('Failed to fetch subfs record:', uri, err);
204 return null;
205 }
206}
207
208/**
209 * Replace subfs nodes in a directory tree with their actual content
210 * Subfs entries are "merged" - their root entries are hoisted into the parent directory
211 * This function is recursive - it will keep expanding until no subfs nodes remain
212 * Uses a cache to avoid re-fetching the same subfs records across recursion depths
213 */
214export async function expandSubfsNodes(
215 directory: Directory,
216 pdsEndpoint: string,
217 depth: number = 0,
218 subfsCache: Map<string, SubfsRecord | null> = new Map()
219): Promise<Directory> {
220 const MAX_DEPTH = 10; // Prevent infinite loops
221
222 if (depth >= MAX_DEPTH) {
223 console.error('Max subfs expansion depth reached, stopping to prevent infinite loop');
224 return directory;
225 }
226
227 // Extract all subfs URIs
228 const subfsUris = extractSubfsUris(directory);
229
230 if (subfsUris.length === 0) {
231 // No subfs nodes, return as-is
232 return directory;
233 }
234
235 // Filter to only URIs we haven't fetched yet
236 const uncachedUris = subfsUris.filter(({ uri }) => !subfsCache.has(uri));
237
238 if (uncachedUris.length > 0) {
239 console.log(`[Depth ${depth}] Found ${subfsUris.length} subfs references, fetching ${uncachedUris.length} new records (${subfsUris.length - uncachedUris.length} cached)...`);
240
241 // Fetch only uncached subfs records in parallel
242 const fetchedRecords = await Promise.all(
243 uncachedUris.map(async ({ uri }) => {
244 const record = await fetchSubfsRecord(uri, pdsEndpoint);
245 return { uri, record };
246 })
247 );
248
249 // Add fetched records to cache
250 for (const { uri, record } of fetchedRecords) {
251 subfsCache.set(uri, record);
252 }
253 } else {
254 console.log(`[Depth ${depth}] Found ${subfsUris.length} subfs references, all cached`);
255 }
256
257 // Build a map of path -> root entries to merge using the cache
258 // Note: SubFS entries are compatible with FS entries at runtime
259 const subfsMap = new Map<string, Entry[]>();
260 for (const { uri, path } of subfsUris) {
261 const record = subfsCache.get(uri);
262 if (record && record.root && record.root.entries) {
263 subfsMap.set(path, record.root.entries as unknown as Entry[]);
264 }
265 }
266
267 // Replace subfs nodes by merging their root entries into the parent directory
268 function replaceSubfsInEntries(entries: Entry[], currentPath: string = ''): Entry[] {
269 const result: Entry[] = [];
270
271 for (const entry of entries) {
272 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
273 const node = entry.node;
274
275 if ('type' in node && node.type === 'subfs') {
276 // Check if this is a flat merge or subdirectory merge (default to flat if not specified)
277 const subfsNode = node as any;
278 const isFlat = subfsNode.flat !== false; // Default to true
279 const subfsEntries = subfsMap.get(fullPath);
280
281 if (subfsEntries) {
282 console.log(`[Depth ${depth}] Merging subfs node at ${fullPath} (${subfsEntries.length} entries, flat: ${isFlat})`);
283
284 if (isFlat) {
285 // Flat merge: hoist entries directly into parent directory
286 const processedEntries = replaceSubfsInEntries(subfsEntries, currentPath);
287 result.push(...processedEntries);
288 } else {
289 // Subdirectory merge: create a directory with the subfs node's name
290 const processedEntries = replaceSubfsInEntries(subfsEntries, fullPath);
291 const directoryNode: Directory = {
292 type: 'directory',
293 entries: processedEntries
294 };
295 result.push({
296 name: entry.name,
297 node: directoryNode as any // Type assertion needed due to lexicon type complexity
298 });
299 }
300 } else {
301 // If not in map yet, preserve the subfs node for next recursion depth
302 console.log(`[Depth ${depth}] Subfs at ${fullPath} not yet fetched, preserving for next iteration`);
303 result.push(entry);
304 }
305 } else if ('type' in node && node.type === 'directory' && 'entries' in node) {
306 // Recursively process subdirectories
307 result.push({
308 ...entry,
309 node: {
310 ...node,
311 entries: replaceSubfsInEntries(node.entries, fullPath)
312 }
313 });
314 } else {
315 // Regular file entry
316 result.push(entry);
317 }
318 }
319
320 return result;
321 }
322
323 const partiallyExpanded = {
324 ...directory,
325 entries: replaceSubfsInEntries(directory.entries)
326 };
327
328 // Recursively expand any remaining subfs nodes (e.g., nested subfs inside parent subfs)
329 // Pass the cache to avoid re-fetching records
330 return expandSubfsNodes(partiallyExpanded, pdsEndpoint, depth + 1, subfsCache);
331}
332
333
334export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> {
335 console.log('Caching site', did, rkey);
336
337 if (!record.root) {
338 console.error('Record missing root directory:', JSON.stringify(record, null, 2));
339 throw new Error('Invalid record structure: missing root directory');
340 }
341
342 if (!record.root.entries || !Array.isArray(record.root.entries)) {
343 console.error('Record root missing entries array:', JSON.stringify(record.root, null, 2));
344 throw new Error('Invalid record structure: root missing entries array');
345 }
346
347 // Expand subfs nodes before caching
348 const expandedRoot = await expandSubfsNodes(record.root, pdsEndpoint);
349
350 // Verify all subfs nodes were expanded
351 const remainingSubfs = extractSubfsUris(expandedRoot);
352 if (remainingSubfs.length > 0) {
353 console.warn(`[Cache] Warning: ${remainingSubfs.length} subfs nodes remain unexpanded after expansion`, remainingSubfs);
354 }
355
356 // Validate file count limit
357 const fileCount = countFilesInDirectory(expandedRoot);
358 if (fileCount > MAX_FILE_COUNT) {
359 throw new Error(`Site exceeds file count limit: ${fileCount} files (max ${MAX_FILE_COUNT})`);
360 }
361 console.log(`[Cache] File count validation passed: ${fileCount} files (limit: ${MAX_FILE_COUNT})`);
362
363 // Validate total size from blob metadata
364 const totalBlobSize = calculateTotalBlobSize(expandedRoot);
365 if (totalBlobSize > MAX_SITE_SIZE) {
366 throw new Error(`Site exceeds size limit: ${(totalBlobSize / 1024 / 1024).toFixed(2)}MB (max ${(MAX_SITE_SIZE / 1024 / 1024).toFixed(0)}MB)`);
367 }
368 console.log(`[Cache] Size validation passed: ${(totalBlobSize / 1024 / 1024).toFixed(2)}MB (limit: ${(MAX_SITE_SIZE / 1024 / 1024).toFixed(0)}MB)`);
369
370 // Get existing cache metadata to check for incremental updates
371 const existingMetadata = await getCacheMetadata(did, rkey);
372 const existingFileCids = existingMetadata?.fileCids || {};
373
374 // Use a temporary directory with timestamp to avoid collisions
375 const tempSuffix = `.tmp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
376 const tempDir = `${CACHE_DIR}/${did}/${rkey}${tempSuffix}`;
377 const finalDir = `${CACHE_DIR}/${did}/${rkey}`;
378
379 try {
380 // Collect file CIDs from the new record (using expanded root)
381 const newFileCids: Record<string, string> = {};
382 collectFileCidsFromEntries(expandedRoot.entries, '', newFileCids);
383
384 // Fetch site settings (optional)
385 const settings = await fetchSiteSettings(did, rkey);
386
387 // Download/copy files to temporary directory (with incremental logic, using expanded root)
388 await cacheFiles(did, rkey, expandedRoot.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir);
389 await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids, settings);
390
391 // Atomically replace old cache with new cache
392 // On POSIX systems (Linux/macOS), rename is atomic
393 if (existsSync(finalDir)) {
394 // Rename old directory to backup
395 const backupDir = `${finalDir}.old-${Date.now()}`;
396 await rename(finalDir, backupDir);
397
398 try {
399 // Rename new directory to final location
400 await rename(tempDir, finalDir);
401
402 // Clean up old backup
403 rmSync(backupDir, { recursive: true, force: true });
404 } catch (err) {
405 // If rename failed, restore backup
406 if (existsSync(backupDir) && !existsSync(finalDir)) {
407 await rename(backupDir, finalDir);
408 }
409 throw err;
410 }
411 } else {
412 // No existing cache, just rename temp to final
413 await rename(tempDir, finalDir);
414 }
415
416 console.log('Successfully cached site atomically', did, rkey);
417 } catch (err) {
418 // Clean up temp directory on failure
419 if (existsSync(tempDir)) {
420 rmSync(tempDir, { recursive: true, force: true });
421 }
422 throw err;
423 }
424}
425
426
427async function cacheFiles(
428 did: string,
429 site: string,
430 entries: Entry[],
431 pdsEndpoint: string,
432 pathPrefix: string,
433 dirSuffix: string = '',
434 existingFileCids: Record<string, string> = {},
435 existingCacheDir?: string
436): Promise<void> {
437 // Collect file tasks, separating unchanged files from new/changed files
438 const downloadTasks: Array<() => Promise<void>> = [];
439 const copyTasks: Array<() => Promise<void>> = [];
440
441 function collectFileTasks(
442 entries: Entry[],
443 currentPathPrefix: string
444 ) {
445 for (const entry of entries) {
446 const currentPath = currentPathPrefix ? `${currentPathPrefix}/${entry.name}` : entry.name;
447 const node = entry.node;
448
449 if ('type' in node && node.type === 'directory' && 'entries' in node) {
450 collectFileTasks(node.entries, currentPath);
451 } else if ('type' in node && node.type === 'file' && 'blob' in node) {
452 const fileNode = node as File;
453 const cid = extractBlobCid(fileNode.blob);
454
455 // Check if file is unchanged (same CID as existing cache)
456 if (cid && existingFileCids[currentPath] === cid && existingCacheDir) {
457 // File unchanged - copy from existing cache instead of downloading
458 copyTasks.push(() => copyExistingFile(
459 did,
460 site,
461 currentPath,
462 dirSuffix,
463 existingCacheDir
464 ));
465 } else {
466 // File new or changed - download it
467 downloadTasks.push(() => cacheFileBlob(
468 did,
469 site,
470 currentPath,
471 fileNode.blob,
472 pdsEndpoint,
473 fileNode.encoding,
474 fileNode.mimeType,
475 fileNode.base64,
476 dirSuffix
477 ));
478 }
479 }
480 }
481 }
482
483 collectFileTasks(entries, pathPrefix);
484
485 console.log(`[Incremental Update] Files to copy: ${copyTasks.length}, Files to download: ${downloadTasks.length}`);
486
487 // Copy unchanged files in parallel (fast local operations) - increased limit for better performance
488 const copyLimit = 50;
489 for (let i = 0; i < copyTasks.length; i += copyLimit) {
490 const batch = copyTasks.slice(i, i + copyLimit);
491 await Promise.all(batch.map(task => task()));
492 if (copyTasks.length > copyLimit) {
493 console.log(`[Cache Progress] Copied ${Math.min(i + copyLimit, copyTasks.length)}/${copyTasks.length} unchanged files`);
494 }
495 }
496
497 // Download new/changed files concurrently - increased from 3 to 20 for much better performance
498 const downloadLimit = 20;
499 let successCount = 0;
500 let failureCount = 0;
501
502 for (let i = 0; i < downloadTasks.length; i += downloadLimit) {
503 const batch = downloadTasks.slice(i, i + downloadLimit);
504 const results = await Promise.allSettled(batch.map(task => task()));
505
506 // Count successes and failures
507 results.forEach((result, index) => {
508 if (result.status === 'fulfilled') {
509 successCount++;
510 } else {
511 failureCount++;
512 console.error(`[Cache] Failed to download file (continuing with others):`, result.reason);
513 }
514 });
515
516 if (downloadTasks.length > downloadLimit) {
517 console.log(`[Cache Progress] Downloaded ${Math.min(i + downloadLimit, downloadTasks.length)}/${downloadTasks.length} files (${failureCount} failed)`);
518 }
519 }
520
521 if (failureCount > 0) {
522 console.warn(`[Cache] Completed with ${successCount} successful and ${failureCount} failed file downloads`);
523 }
524}
525
526/**
527 * Copy an unchanged file from existing cache to new cache location
528 */
529async function copyExistingFile(
530 did: string,
531 site: string,
532 filePath: string,
533 dirSuffix: string,
534 existingCacheDir: string
535): Promise<void> {
536 const { copyFile } = await import('fs/promises');
537
538 const sourceFile = `${existingCacheDir}/${filePath}`;
539 const destFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
540 const destDir = destFile.substring(0, destFile.lastIndexOf('/'));
541
542 // Create destination directory if needed
543 if (destDir && !existsSync(destDir)) {
544 mkdirSync(destDir, { recursive: true });
545 }
546
547 try {
548 // Copy the file
549 await copyFile(sourceFile, destFile);
550
551 // Copy metadata file if it exists
552 const sourceMetaFile = `${sourceFile}.meta`;
553 const destMetaFile = `${destFile}.meta`;
554 if (existsSync(sourceMetaFile)) {
555 await copyFile(sourceMetaFile, destMetaFile);
556 }
557 } catch (err) {
558 console.error(`Failed to copy cached file ${filePath}, will attempt download:`, err);
559 throw err;
560 }
561}
562
563async function cacheFileBlob(
564 did: string,
565 site: string,
566 filePath: string,
567 blobRef: any,
568 pdsEndpoint: string,
569 encoding?: 'gzip',
570 mimeType?: string,
571 base64?: boolean,
572 dirSuffix: string = ''
573): Promise<void> {
574 const cid = extractBlobCid(blobRef);
575 if (!cid) {
576 console.error('Could not extract CID from blob', blobRef);
577 return;
578 }
579
580 const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`;
581
582 console.log(`[Cache] Fetching blob for file: ${filePath}, CID: ${cid}`);
583
584 let content = await safeFetchBlob(blobUrl, { maxSize: MAX_BLOB_SIZE, timeout: 300000 });
585
586 // If content is base64-encoded, decode it back to raw binary (gzipped or not)
587 if (base64) {
588 // Decode base64 directly from raw bytes - no string conversion
589 // The blob contains base64-encoded text as raw bytes, decode it in-place
590 const textDecoder = new TextDecoder();
591 const base64String = textDecoder.decode(content);
592 content = Buffer.from(base64String, 'base64');
593 }
594
595 const cacheFile = `${CACHE_DIR}/${did}/${site}${dirSuffix}/${filePath}`;
596 const fileDir = cacheFile.substring(0, cacheFile.lastIndexOf('/'));
597
598 if (fileDir && !existsSync(fileDir)) {
599 mkdirSync(fileDir, { recursive: true });
600 }
601
602 // Use the shared function to determine if this should remain compressed
603 const shouldStayCompressed = shouldCompressMimeType(mimeType);
604
605 // Decompress files that shouldn't be stored compressed
606 if (encoding === 'gzip' && !shouldStayCompressed && content.length >= 2 &&
607 content[0] === 0x1f && content[1] === 0x8b) {
608 try {
609 const { gunzipSync } = await import('zlib');
610 const decompressed = gunzipSync(content);
611 content = decompressed;
612 // Clear the encoding flag since we're storing decompressed
613 encoding = undefined;
614 } catch (error) {
615 console.error(`Failed to decompress ${filePath}, storing original gzipped content:`, error);
616 }
617 }
618
619 await writeFile(cacheFile, content);
620
621 // Store metadata only if file is still compressed
622 if (encoding === 'gzip' && mimeType) {
623 const metaFile = `${cacheFile}.meta`;
624 await writeFile(metaFile, JSON.stringify({ encoding, mimeType }));
625 console.log('Cached file', filePath, content.length, 'bytes (gzipped,', mimeType + ')');
626 } else {
627 console.log('Cached file', filePath, content.length, 'bytes');
628 }
629}
630
631
632export function getCachedFilePath(did: string, site: string, filePath: string): string {
633 const sanitizedPath = sanitizePath(filePath);
634 return `${CACHE_DIR}/${did}/${site}/${sanitizedPath}`;
635}
636
637export function isCached(did: string, site: string): boolean {
638 return existsSync(`${CACHE_DIR}/${did}/${site}`);
639}
640
641async function saveCacheMetadata(did: string, rkey: string, recordCid: string, dirSuffix: string = '', fileCids?: Record<string, string>, settings?: WispSettings | null): Promise<void> {
642 const metadata: CacheMetadata = {
643 recordCid,
644 cachedAt: Date.now(),
645 did,
646 rkey,
647 fileCids,
648 settings: settings || undefined
649 };
650
651 const metadataPath = `${CACHE_DIR}/${did}/${rkey}${dirSuffix}/.metadata.json`;
652 const metadataDir = metadataPath.substring(0, metadataPath.lastIndexOf('/'));
653
654 if (!existsSync(metadataDir)) {
655 mkdirSync(metadataDir, { recursive: true });
656 }
657
658 await writeFile(metadataPath, JSON.stringify(metadata, null, 2));
659}
660
661async function getCacheMetadata(did: string, rkey: string): Promise<CacheMetadata | null> {
662 try {
663 const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
664 if (!existsSync(metadataPath)) return null;
665
666 const content = await readFile(metadataPath, 'utf-8');
667 return JSON.parse(content) as CacheMetadata;
668 } catch (err) {
669 console.error('Failed to read cache metadata', err);
670 return null;
671 }
672}
673
674export async function getCachedSettings(did: string, rkey: string): Promise<WispSettings | null> {
675 const metadata = await getCacheMetadata(did, rkey);
676
677 // If metadata has settings (including explicit null for "no settings"), return them
678 if (metadata && 'settings' in metadata) {
679 return metadata.settings ?? null;
680 }
681
682 // If metadata exists but has never checked for settings, try to fetch from PDS and update cache
683 if (metadata) {
684 console.log('[Cache] Metadata missing settings, fetching from PDS', { did, rkey });
685 try {
686 const settings = await fetchSiteSettings(did, rkey);
687 // Update cache with settings (or null if none found)
688 // This caches the "no settings" state to avoid repeated PDS fetches
689 await updateCacheMetadataSettings(did, rkey, settings);
690 console.log('[Cache] Updated metadata with fetched settings', { did, rkey, hasSettings: !!settings });
691 return settings;
692 } catch (err) {
693 console.error('[Cache] Failed to fetch/update settings', { did, rkey, err });
694 }
695 }
696
697 return null;
698}
699
700export async function updateCacheMetadataSettings(did: string, rkey: string, settings: WispSettings | null): Promise<void> {
701 const metadataPath = `${CACHE_DIR}/${did}/${rkey}/.metadata.json`;
702
703 if (!existsSync(metadataPath)) {
704 console.warn('Metadata file does not exist, cannot update settings', { did, rkey });
705 return;
706 }
707
708 try {
709 // Read existing metadata
710 const content = await readFile(metadataPath, 'utf-8');
711 const metadata = JSON.parse(content) as CacheMetadata;
712
713 // Update settings field
714 // Store null explicitly to cache "no settings" state and avoid repeated fetches
715 metadata.settings = settings ?? null;
716
717 // Write back to disk
718 await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
719 console.log('Updated metadata settings', { did, rkey, hasSettings: !!settings });
720 } catch (err) {
721 console.error('Failed to update metadata settings', err);
722 throw err;
723 }
724}
725
726export async function isCacheValid(did: string, rkey: string, currentRecordCid?: string): Promise<boolean> {
727 const metadata = await getCacheMetadata(did, rkey);
728 if (!metadata) return false;
729
730 // Check if cache has expired (14 days TTL)
731 const cacheAge = Date.now() - metadata.cachedAt;
732 if (cacheAge > CACHE_TTL) {
733 console.log('[Cache] Cache expired for', did, rkey);
734 return false;
735 }
736
737 // If current CID is provided, verify it matches
738 if (currentRecordCid && metadata.recordCid !== currentRecordCid) {
739 console.log('[Cache] CID mismatch for', did, rkey, 'cached:', metadata.recordCid, 'current:', currentRecordCid);
740 return false;
741 }
742
743 return true;
744}