Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import type { BlobRef } from "@atproto/api"; 2import type { Record, Directory, File, Entry } from "../lexicons/types/place/wisp/fs"; 3import { validateRecord } from "../lexicons/types/place/wisp/fs"; 4import { gzipSync } from 'zlib'; 5import { CID } from 'multiformats/cid'; 6import { sha256 } from 'multiformats/hashes/sha2'; 7import * as raw from 'multiformats/codecs/raw'; 8import { createHash } from 'crypto'; 9import * as mf from 'multiformats'; 10 11export interface UploadedFile { 12 name: string; 13 content: Buffer; 14 mimeType: string; 15 size: number; 16 compressed?: boolean; 17 originalMimeType?: string; 18} 19 20export interface FileUploadResult { 21 hash: string; 22 blobRef: BlobRef; 23 encoding?: 'gzip'; 24 mimeType?: string; 25 base64?: boolean; 26} 27 28export interface ProcessedDirectory { 29 directory: Directory; 30 fileCount: number; 31} 32 33/** 34 * Determine if a file should be gzip compressed based on its MIME type 35 */ 36export function shouldCompressFile(mimeType: string): boolean { 37 // Compress text-based files 38 const compressibleTypes = [ 39 'text/html', 40 'text/css', 41 'text/javascript', 42 'application/javascript', 43 'application/json', 44 'image/svg+xml', 45 'text/xml', 46 'application/xml', 47 'text/plain', 48 'application/x-javascript' 49 ]; 50 51 // Check if mime type starts with any compressible type 52 return compressibleTypes.some(type => mimeType.startsWith(type)); 53} 54 55/** 56 * Compress a file using gzip with deterministic output 57 */ 58export function compressFile(content: Buffer): Buffer { 59 return gzipSync(content, { 60 level: 9 61 }); 62} 63 64/** 65 * Process uploaded files into a directory structure 66 */ 67export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 68 const entries: Entry[] = []; 69 let fileCount = 0; 70 71 // Group files by directory 72 const directoryMap = new Map<string, UploadedFile[]>(); 73 74 for (const file of files) { 75 // Skip undefined/null files (defensive) 76 if (!file || !file.name) { 77 console.error('Skipping undefined or invalid file in processUploadedFiles'); 78 continue; 79 } 80 81 // Remove any base folder name from the path 82 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 83 const parts = normalizedPath.split('/'); 84 85 if (parts.length === 1) { 86 // Root level file 87 entries.push({ 88 name: parts[0], 89 node: { 90 $type: 'place.wisp.fs#file' as const, 91 type: 'file' as const, 92 blob: undefined as any // Will be filled after upload 93 } 94 }); 95 fileCount++; 96 } else { 97 // File in subdirectory 98 const dirPath = parts.slice(0, -1).join('/'); 99 if (!directoryMap.has(dirPath)) { 100 directoryMap.set(dirPath, []); 101 } 102 directoryMap.get(dirPath)!.push({ 103 ...file, 104 name: normalizedPath 105 }); 106 } 107 } 108 109 // Process subdirectories 110 for (const [dirPath, dirFiles] of directoryMap) { 111 const dirEntries: Entry[] = []; 112 113 for (const file of dirFiles) { 114 const fileName = file.name.split('/').pop()!; 115 dirEntries.push({ 116 name: fileName, 117 node: { 118 $type: 'place.wisp.fs#file' as const, 119 type: 'file' as const, 120 blob: undefined as any // Will be filled after upload 121 } 122 }); 123 fileCount++; 124 } 125 126 // Build nested directory structure 127 const pathParts = dirPath.split('/'); 128 let currentEntries = entries; 129 130 for (let i = 0; i < pathParts.length; i++) { 131 const part = pathParts[i]; 132 const isLast = i === pathParts.length - 1; 133 134 let existingEntry = currentEntries.find(e => e.name === part); 135 136 if (!existingEntry) { 137 const newDir = { 138 $type: 'place.wisp.fs#directory' as const, 139 type: 'directory' as const, 140 entries: isLast ? dirEntries : [] 141 }; 142 143 existingEntry = { 144 name: part, 145 node: newDir 146 }; 147 currentEntries.push(existingEntry); 148 } else if ('entries' in existingEntry.node && isLast) { 149 (existingEntry.node as any).entries.push(...dirEntries); 150 } 151 152 if (existingEntry && 'entries' in existingEntry.node) { 153 currentEntries = (existingEntry.node as any).entries; 154 } 155 } 156 } 157 158 const result = { 159 directory: { 160 $type: 'place.wisp.fs#directory' as const, 161 type: 'directory' as const, 162 entries 163 }, 164 fileCount 165 }; 166 167 return result; 168} 169 170/** 171 * Create the manifest record for a site 172 */ 173export function createManifest( 174 siteName: string, 175 root: Directory, 176 fileCount: number 177): Record { 178 const manifest = { 179 $type: 'place.wisp.fs' as const, 180 site: siteName, 181 root, 182 fileCount, 183 createdAt: new Date().toISOString() 184 }; 185 186 // Validate the manifest before returning 187 const validationResult = validateRecord(manifest); 188 if (!validationResult.success) { 189 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 190 } 191 192 return manifest; 193} 194 195/** 196 * Update file blobs in directory structure after upload 197 * Uses path-based matching to correctly match files in nested directories 198 */ 199export function updateFileBlobs( 200 directory: Directory, 201 uploadResults: FileUploadResult[], 202 filePaths: string[], 203 currentPath: string = '' 204): Directory { 205 const updatedEntries = directory.entries.map(entry => { 206 if ('type' in entry.node && entry.node.type === 'file') { 207 // Build the full path for this file 208 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 209 210 // Find exact match in filePaths (need to handle normalized paths) 211 const fileIndex = filePaths.findIndex((path) => { 212 // Normalize both paths by removing leading base folder 213 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 214 const normalizedEntryPath = fullPath; 215 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 216 }); 217 218 if (fileIndex !== -1 && uploadResults[fileIndex]) { 219 const result = uploadResults[fileIndex]; 220 const blobRef = result.blobRef; 221 222 return { 223 ...entry, 224 node: { 225 $type: 'place.wisp.fs#file' as const, 226 type: 'file' as const, 227 blob: blobRef, 228 ...(result.encoding && { encoding: result.encoding }), 229 ...(result.mimeType && { mimeType: result.mimeType }), 230 ...(result.base64 && { base64: result.base64 }) 231 } 232 }; 233 } else { 234 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`); 235 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : ''); 236 } 237 } else if ('type' in entry.node && entry.node.type === 'directory') { 238 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 239 return { 240 ...entry, 241 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath) 242 }; 243 } 244 return entry; 245 }) as Entry[]; 246 247 const result = { 248 $type: 'place.wisp.fs#directory' as const, 249 type: 'directory' as const, 250 entries: updatedEntries 251 }; 252 253 return result; 254} 255 256/** 257 * Compute CID (Content Identifier) for blob content 258 * Uses the same algorithm as AT Protocol: CIDv1 with raw codec and SHA-256 259 * Based on @atproto/common/src/ipld.ts sha256RawToCid implementation 260 */ 261export function computeCID(content: Buffer): string { 262 // Use node crypto to compute sha256 hash (same as AT Protocol) 263 const hash = createHash('sha256').update(content).digest(); 264 // Create digest object from hash bytes 265 const digest = mf.digest.create(sha256.code, hash); 266 // Create CIDv1 with raw codec 267 const cid = CID.createV1(raw.code, digest); 268 return cid.toString(); 269} 270 271/** 272 * Extract blob information from a directory tree 273 * Returns a map of file paths to their blob refs and CIDs 274 */ 275export function extractBlobMap( 276 directory: Directory, 277 currentPath: string = '' 278): Map<string, { blobRef: BlobRef; cid: string }> { 279 const blobMap = new Map<string, { blobRef: BlobRef; cid: string }>(); 280 281 for (const entry of directory.entries) { 282 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 283 284 if ('type' in entry.node && entry.node.type === 'file') { 285 const fileNode = entry.node as File; 286 // AT Protocol SDK returns BlobRef class instances, not plain objects 287 // The ref is a CID instance that can be converted to string 288 if (fileNode.blob && fileNode.blob.ref) { 289 const cidString = fileNode.blob.ref.toString(); 290 blobMap.set(fullPath, { 291 blobRef: fileNode.blob, 292 cid: cidString 293 }); 294 } 295 } else if ('type' in entry.node && entry.node.type === 'directory') { 296 const subMap = extractBlobMap(entry.node as Directory, fullPath); 297 subMap.forEach((value, key) => blobMap.set(key, value)); 298 } 299 } 300 301 return blobMap; 302}