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'; 5 6export interface UploadedFile { 7 name: string; 8 content: Buffer; 9 mimeType: string; 10 size: number; 11 compressed?: boolean; 12 originalMimeType?: string; 13} 14 15export interface FileUploadResult { 16 hash: string; 17 blobRef: BlobRef; 18 encoding?: 'gzip'; 19 mimeType?: string; 20 base64?: boolean; 21} 22 23export interface ProcessedDirectory { 24 directory: Directory; 25 fileCount: number; 26} 27 28/** 29 * Determine if a file should be gzip compressed based on its MIME type 30 */ 31export function shouldCompressFile(mimeType: string): boolean { 32 // Compress text-based files 33 const compressibleTypes = [ 34 'text/html', 35 'text/css', 36 'text/javascript', 37 'application/javascript', 38 'application/json', 39 'image/svg+xml', 40 'text/xml', 41 'application/xml', 42 'text/plain', 43 'application/x-javascript' 44 ]; 45 46 // Check if mime type starts with any compressible type 47 return compressibleTypes.some(type => mimeType.startsWith(type)); 48} 49 50/** 51 * Compress a file using gzip 52 */ 53export function compressFile(content: Buffer): Buffer { 54 return gzipSync(content, { level: 9 }); 55} 56 57/** 58 * Process uploaded files into a directory structure 59 */ 60export function processUploadedFiles(files: UploadedFile[]): ProcessedDirectory { 61 const entries: Entry[] = []; 62 let fileCount = 0; 63 64 // Group files by directory 65 const directoryMap = new Map<string, UploadedFile[]>(); 66 67 for (const file of files) { 68 // Remove any base folder name from the path 69 const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 70 const parts = normalizedPath.split('/'); 71 72 if (parts.length === 1) { 73 // Root level file 74 entries.push({ 75 name: parts[0], 76 node: { 77 $type: 'place.wisp.fs#file' as const, 78 type: 'file' as const, 79 blob: undefined as any // Will be filled after upload 80 } 81 }); 82 fileCount++; 83 } else { 84 // File in subdirectory 85 const dirPath = parts.slice(0, -1).join('/'); 86 if (!directoryMap.has(dirPath)) { 87 directoryMap.set(dirPath, []); 88 } 89 directoryMap.get(dirPath)!.push({ 90 ...file, 91 name: normalizedPath 92 }); 93 } 94 } 95 96 // Process subdirectories 97 for (const [dirPath, dirFiles] of directoryMap) { 98 const dirEntries: Entry[] = []; 99 100 for (const file of dirFiles) { 101 const fileName = file.name.split('/').pop()!; 102 dirEntries.push({ 103 name: fileName, 104 node: { 105 $type: 'place.wisp.fs#file' as const, 106 type: 'file' as const, 107 blob: undefined as any // Will be filled after upload 108 } 109 }); 110 fileCount++; 111 } 112 113 // Build nested directory structure 114 const pathParts = dirPath.split('/'); 115 let currentEntries = entries; 116 117 for (let i = 0; i < pathParts.length; i++) { 118 const part = pathParts[i]; 119 const isLast = i === pathParts.length - 1; 120 121 let existingEntry = currentEntries.find(e => e.name === part); 122 123 if (!existingEntry) { 124 const newDir = { 125 $type: 'place.wisp.fs#directory' as const, 126 type: 'directory' as const, 127 entries: isLast ? dirEntries : [] 128 }; 129 130 existingEntry = { 131 name: part, 132 node: newDir 133 }; 134 currentEntries.push(existingEntry); 135 } else if ('entries' in existingEntry.node && isLast) { 136 (existingEntry.node as any).entries.push(...dirEntries); 137 } 138 139 if (existingEntry && 'entries' in existingEntry.node) { 140 currentEntries = (existingEntry.node as any).entries; 141 } 142 } 143 } 144 145 const result = { 146 directory: { 147 $type: 'place.wisp.fs#directory' as const, 148 type: 'directory' as const, 149 entries 150 }, 151 fileCount 152 }; 153 154 return result; 155} 156 157/** 158 * Create the manifest record for a site 159 */ 160export function createManifest( 161 siteName: string, 162 root: Directory, 163 fileCount: number 164): Record { 165 const manifest = { 166 $type: 'place.wisp.fs' as const, 167 site: siteName, 168 root, 169 fileCount, 170 createdAt: new Date().toISOString() 171 }; 172 173 // Validate the manifest before returning 174 const validationResult = validateRecord(manifest); 175 if (!validationResult.success) { 176 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 177 } 178 179 return manifest; 180} 181 182/** 183 * Update file blobs in directory structure after upload 184 * Uses path-based matching to correctly match files in nested directories 185 */ 186export function updateFileBlobs( 187 directory: Directory, 188 uploadResults: FileUploadResult[], 189 filePaths: string[], 190 currentPath: string = '' 191): Directory { 192 const updatedEntries = directory.entries.map(entry => { 193 if ('type' in entry.node && entry.node.type === 'file') { 194 // Build the full path for this file 195 const fullPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 196 197 // Find exact match in filePaths (need to handle normalized paths) 198 const fileIndex = filePaths.findIndex((path) => { 199 // Normalize both paths by removing leading base folder 200 const normalizedUploadPath = path.replace(/^[^\/]*\//, ''); 201 const normalizedEntryPath = fullPath; 202 return normalizedUploadPath === normalizedEntryPath || path === fullPath; 203 }); 204 205 if (fileIndex !== -1 && uploadResults[fileIndex]) { 206 const result = uploadResults[fileIndex]; 207 const blobRef = result.blobRef; 208 209 return { 210 ...entry, 211 node: { 212 $type: 'place.wisp.fs#file' as const, 213 type: 'file' as const, 214 blob: blobRef, 215 ...(result.encoding && { encoding: result.encoding }), 216 ...(result.mimeType && { mimeType: result.mimeType }), 217 ...(result.base64 && { base64: result.base64 }) 218 } 219 }; 220 } else { 221 console.error(`❌ BLOB MATCHING ERROR: Could not find blob for file: ${fullPath}`); 222 console.error(` Available paths:`, filePaths.slice(0, 10), filePaths.length > 10 ? `... and ${filePaths.length - 10} more` : ''); 223 } 224 } else if ('type' in entry.node && entry.node.type === 'directory') { 225 const dirPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; 226 return { 227 ...entry, 228 node: updateFileBlobs(entry.node as Directory, uploadResults, filePaths, dirPath) 229 }; 230 } 231 return entry; 232 }) as Entry[]; 233 234 const result = { 235 $type: 'place.wisp.fs#directory' as const, 236 type: 'directory' as const, 237 entries: updatedEntries 238 }; 239 240 return result; 241}