Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

add subfs expansion support to hosting service

Implements transparent subfs record fetching and expansion:
- extractSubfsUris() finds all subfs references in directory tree
- fetchSubfsRecord() retrieves subfs records from PDS
- expandSubfsNodes() replaces subfs nodes with actual content

When caching sites, the hosting service now:
1. Detects subfs nodes in the manifest
2. Fetches all referenced subfs records in parallel
3. Expands the tree by replacing subfs nodes with their content
4. Caches the fully expanded site normally

This makes subfs completely transparent to end users.

Changed files
+136 -4
hosting-service
src
lib
+136 -4
hosting-service/src/lib/utils.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { Record as WispFsRecord, Directory, Entry, File } from '../lexicon/types/place/wisp/fs'; 3 + import type { Record as SubfsRecord } from '../lexicon/types/place/wisp/subfs'; 3 4 import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 4 5 import { writeFile, readFile, rename } from 'fs/promises'; 5 6 import { safeFetchJson, safeFetchBlob } from './safe-fetch'; ··· 189 190 return null; 190 191 } 191 192 193 + /** 194 + * Extract all subfs URIs from a directory tree with their mount paths 195 + */ 196 + function extractSubfsUris(directory: Directory, currentPath: string = ''): Array<{ uri: string; path: string }> { 197 + const uris: Array<{ uri: string; path: string }> = []; 198 + 199 + for (const entry of directory.entries) { 200 + const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 201 + 202 + if ('type' in entry.node) { 203 + if (entry.node.type === 'subfs') { 204 + // Subfs node with subject URI 205 + const subfsNode = entry.node as any; 206 + if (subfsNode.subject) { 207 + uris.push({ uri: subfsNode.subject, path: fullPath }); 208 + } 209 + } else if (entry.node.type === 'directory') { 210 + // Recursively search subdirectories 211 + const subUris = extractSubfsUris(entry.node as Directory, fullPath); 212 + uris.push(...subUris); 213 + } 214 + } 215 + } 216 + 217 + return uris; 218 + } 219 + 220 + /** 221 + * Fetch a subfs record from the PDS 222 + */ 223 + async function fetchSubfsRecord(uri: string, pdsEndpoint: string): Promise<SubfsRecord | null> { 224 + try { 225 + // Parse URI: at://did/collection/rkey 226 + const parts = uri.replace('at://', '').split('/'); 227 + if (parts.length < 3) { 228 + console.error('Invalid subfs URI:', uri); 229 + return null; 230 + } 231 + 232 + const did = parts[0]; 233 + const collection = parts[1]; 234 + const rkey = parts[2]; 235 + 236 + // Fetch the record from PDS 237 + const url = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=${encodeURIComponent(collection)}&rkey=${encodeURIComponent(rkey)}`; 238 + const response = await safeFetchJson(url); 239 + 240 + if (!response || !response.value) { 241 + console.error('Subfs record not found:', uri); 242 + return null; 243 + } 244 + 245 + return response.value as SubfsRecord; 246 + } catch (err) { 247 + console.error('Failed to fetch subfs record:', uri, err); 248 + return null; 249 + } 250 + } 251 + 252 + /** 253 + * Replace subfs nodes in a directory tree with their actual content 254 + */ 255 + async function expandSubfsNodes(directory: Directory, pdsEndpoint: string): Promise<Directory> { 256 + // Extract all subfs URIs 257 + const subfsUris = extractSubfsUris(directory); 258 + 259 + if (subfsUris.length === 0) { 260 + // No subfs nodes, return as-is 261 + return directory; 262 + } 263 + 264 + console.log(`Found ${subfsUris.length} subfs records, fetching...`); 265 + 266 + // Fetch all subfs records in parallel 267 + const subfsRecords = await Promise.all( 268 + subfsUris.map(async ({ uri, path }) => { 269 + const record = await fetchSubfsRecord(uri, pdsEndpoint); 270 + return { record, path }; 271 + }) 272 + ); 273 + 274 + // Build a map of path -> directory content 275 + const subfsMap = new Map<string, Directory>(); 276 + for (const { record, path } of subfsRecords) { 277 + if (record && record.root) { 278 + subfsMap.set(path, record.root); 279 + } 280 + } 281 + 282 + // Replace subfs nodes with their actual content 283 + function replaceSubfsInEntries(entries: Entry[], currentPath: string = ''): Entry[] { 284 + return entries.map(entry => { 285 + const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 286 + const node = entry.node; 287 + 288 + if ('type' in node && node.type === 'subfs') { 289 + // Replace with actual directory content 290 + const subfsDir = subfsMap.get(fullPath); 291 + if (subfsDir) { 292 + console.log(`Expanding subfs node at ${fullPath}`); 293 + return { 294 + ...entry, 295 + node: subfsDir 296 + }; 297 + } 298 + // If fetch failed, keep the subfs node (will be skipped later) 299 + return entry; 300 + } else if ('type' in node && node.type === 'directory' && 'entries' in node) { 301 + // Recursively process subdirectories 302 + return { 303 + ...entry, 304 + node: { 305 + ...node, 306 + entries: replaceSubfsInEntries(node.entries, fullPath) 307 + } 308 + }; 309 + } 310 + 311 + return entry; 312 + }); 313 + } 314 + 315 + return { 316 + ...directory, 317 + entries: replaceSubfsInEntries(directory.entries) 318 + }; 319 + } 320 + 192 321 export async function downloadAndCacheSite(did: string, rkey: string, record: WispFsRecord, pdsEndpoint: string, recordCid: string): Promise<void> { 193 322 console.log('Caching site', did, rkey); 194 323 ··· 201 330 console.error('Record root missing entries array:', JSON.stringify(record.root, null, 2)); 202 331 throw new Error('Invalid record structure: root missing entries array'); 203 332 } 333 + 334 + // Expand subfs nodes before caching 335 + const expandedRoot = await expandSubfsNodes(record.root, pdsEndpoint); 204 336 205 337 // Get existing cache metadata to check for incremental updates 206 338 const existingMetadata = await getCacheMetadata(did, rkey); ··· 212 344 const finalDir = `${CACHE_DIR}/${did}/${rkey}`; 213 345 214 346 try { 215 - // Collect file CIDs from the new record 347 + // Collect file CIDs from the new record (using expanded root) 216 348 const newFileCids: Record<string, string> = {}; 217 - collectFileCidsFromEntries(record.root.entries, '', newFileCids); 349 + collectFileCidsFromEntries(expandedRoot.entries, '', newFileCids); 218 350 219 - // Download/copy files to temporary directory (with incremental logic) 220 - await cacheFiles(did, rkey, record.root.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir); 351 + // Download/copy files to temporary directory (with incremental logic, using expanded root) 352 + await cacheFiles(did, rkey, expandedRoot.entries, pdsEndpoint, '', tempSuffix, existingFileCids, finalDir); 221 353 await saveCacheMetadata(did, rkey, recordCid, tempSuffix, newFileCids); 222 354 223 355 // Atomically replace old cache with new cache