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

implement automatic subfs splitting for large sites

- Splits manifests >140KB or sites with 250+ files into subfs records
- Creates subfs records with TID-based rkeys for uniqueness
- Splits largest directories first until manifest fits under limit
- Fetches and merges blob maps from all subfs records for full reuse
- Deletes old subfs records after successful upload

Upload flow:
1. Fetch existing main + all subfs records
2. Build combined blob map for incremental updates
3. Upload blobs (reuses across all records)
4. Split directories into subfs if needed (auto-detect)
5. Upload main manifest with subfs references
6. Clean up orphaned subfs records from previous version

Changed files
+1013 -327
public
editor
src
lib
routes
+141 -22
public/editor/tabs/UploadTab.tsx
··· 1 - import { useState, useEffect } from 'react' 1 + import { useState, useEffect, useRef } from 'react' 2 2 import { 3 3 Card, 4 4 CardContent, ··· 40 40 const [skippedFiles, setSkippedFiles] = useState<Array<{ name: string; reason: string }>>([]) 41 41 const [uploadedCount, setUploadedCount] = useState(0) 42 42 43 + // Keep SSE connection alive across tab switches 44 + const eventSourceRef = useRef<EventSource | null>(null) 45 + const currentJobIdRef = useRef<string | null>(null) 46 + 43 47 // Auto-switch to 'new' mode if no sites exist 44 48 useEffect(() => { 45 49 if (!sitesLoading && sites.length === 0 && siteMode === 'existing') { ··· 47 51 } 48 52 }, [sites, sitesLoading, siteMode]) 49 53 54 + // Cleanup SSE connection on unmount 55 + useEffect(() => { 56 + return () => { 57 + // Don't close the connection on unmount (tab switch) 58 + // It will be reused when the component remounts 59 + } 60 + }, []) 61 + 50 62 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 51 63 if (e.target.files && e.target.files.length > 0) { 52 64 setSelectedFiles(e.target.files) 53 65 } 54 66 } 55 67 68 + const setupSSE = (jobId: string) => { 69 + // Close existing connection if any 70 + if (eventSourceRef.current) { 71 + eventSourceRef.current.close() 72 + } 73 + 74 + currentJobIdRef.current = jobId 75 + const eventSource = new EventSource(`/wisp/upload-progress/${jobId}`) 76 + eventSourceRef.current = eventSource 77 + 78 + eventSource.addEventListener('progress', (event) => { 79 + const progressData = JSON.parse(event.data) 80 + const { progress, status } = progressData 81 + 82 + // Update progress message based on phase 83 + let message = 'Processing...' 84 + if (progress.phase === 'validating') { 85 + message = 'Validating files...' 86 + } else if (progress.phase === 'compressing') { 87 + const current = progress.filesProcessed || 0 88 + const total = progress.totalFiles || 0 89 + message = `Compressing files (${current}/${total})...` 90 + if (progress.currentFile) { 91 + message += ` - ${progress.currentFile}` 92 + } 93 + } else if (progress.phase === 'uploading') { 94 + const uploaded = progress.filesUploaded || 0 95 + const reused = progress.filesReused || 0 96 + const total = progress.totalFiles || 0 97 + message = `Uploading to PDS (${uploaded + reused}/${total})...` 98 + } else if (progress.phase === 'creating_manifest') { 99 + message = 'Creating manifest...' 100 + } else if (progress.phase === 'finalizing') { 101 + message = 'Finalizing upload...' 102 + } 103 + 104 + setUploadProgress(message) 105 + }) 106 + 107 + eventSource.addEventListener('done', (event) => { 108 + const result = JSON.parse(event.data) 109 + eventSource.close() 110 + eventSourceRef.current = null 111 + currentJobIdRef.current = null 112 + 113 + setUploadProgress('Upload complete!') 114 + setSkippedFiles(result.skippedFiles || []) 115 + setUploadedCount(result.uploadedCount || result.fileCount || 0) 116 + setSelectedSiteRkey('') 117 + setNewSiteName('') 118 + setSelectedFiles(null) 119 + 120 + // Refresh sites list 121 + onUploadComplete() 122 + 123 + // Reset form 124 + const resetDelay = result.skippedFiles && result.skippedFiles.length > 0 ? 4000 : 1500 125 + setTimeout(() => { 126 + setUploadProgress('') 127 + setSkippedFiles([]) 128 + setUploadedCount(0) 129 + setIsUploading(false) 130 + }, resetDelay) 131 + }) 132 + 133 + eventSource.addEventListener('error', (event) => { 134 + const errorData = JSON.parse((event as any).data || '{}') 135 + eventSource.close() 136 + eventSourceRef.current = null 137 + currentJobIdRef.current = null 138 + 139 + console.error('Upload error:', errorData) 140 + alert( 141 + `Upload failed: ${errorData.error || 'Unknown error'}` 142 + ) 143 + setIsUploading(false) 144 + setUploadProgress('') 145 + }) 146 + 147 + eventSource.onerror = () => { 148 + eventSource.close() 149 + eventSourceRef.current = null 150 + currentJobIdRef.current = null 151 + 152 + console.error('SSE connection error') 153 + alert('Lost connection to upload progress. The upload may still be processing.') 154 + setIsUploading(false) 155 + setUploadProgress('') 156 + } 157 + } 158 + 56 159 const handleUpload = async () => { 57 160 const siteName = siteMode === 'existing' ? selectedSiteRkey : newSiteName 58 161 ··· 74 177 } 75 178 } 76 179 77 - setUploadProgress('Uploading to AT Protocol...') 180 + // If no files, handle synchronously (old behavior) 181 + if (!selectedFiles || selectedFiles.length === 0) { 182 + setUploadProgress('Creating empty site...') 183 + const response = await fetch('/wisp/upload-files', { 184 + method: 'POST', 185 + body: formData 186 + }) 187 + 188 + const data = await response.json() 189 + if (data.success) { 190 + setUploadProgress('Site created!') 191 + setSelectedSiteRkey('') 192 + setNewSiteName('') 193 + setSelectedFiles(null) 194 + 195 + await onUploadComplete() 196 + 197 + setTimeout(() => { 198 + setUploadProgress('') 199 + setIsUploading(false) 200 + }, 1500) 201 + } else { 202 + throw new Error(data.error || 'Upload failed') 203 + } 204 + return 205 + } 206 + 207 + // For file uploads, use SSE for progress 208 + setUploadProgress('Starting upload...') 78 209 const response = await fetch('/wisp/upload-files', { 79 210 method: 'POST', 80 211 body: formData 81 212 }) 82 213 83 214 const data = await response.json() 84 - if (data.success) { 85 - setUploadProgress('Upload complete!') 86 - setSkippedFiles(data.skippedFiles || []) 87 - setUploadedCount(data.uploadedCount || data.fileCount || 0) 88 - setSelectedSiteRkey('') 89 - setNewSiteName('') 90 - setSelectedFiles(null) 215 + if (!data.success || !data.jobId) { 216 + throw new Error(data.error || 'Failed to start upload') 217 + } 91 218 92 - // Refresh sites list 93 - await onUploadComplete() 219 + const jobId = data.jobId 220 + setUploadProgress('Connecting to progress stream...') 94 221 95 - // Reset form - give more time if there are skipped files 96 - const resetDelay = data.skippedFiles && data.skippedFiles.length > 0 ? 4000 : 1500 97 - setTimeout(() => { 98 - setUploadProgress('') 99 - setSkippedFiles([]) 100 - setUploadedCount(0) 101 - setIsUploading(false) 102 - }, resetDelay) 103 - } else { 104 - throw new Error(data.error || 'Upload failed') 105 - } 222 + // Setup SSE connection (persists across tab switches via ref) 223 + setupSSE(jobId) 224 + 106 225 } catch (err) { 107 226 console.error('Upload error:', err) 108 227 alert(
+197
src/lib/upload-jobs.ts
··· 1 + import { logger } from './observability'; 2 + 3 + export type UploadJobStatus = 'pending' | 'processing' | 'uploading' | 'completed' | 'failed'; 4 + 5 + export interface UploadProgress { 6 + filesProcessed: number; 7 + totalFiles: number; 8 + filesUploaded: number; 9 + filesReused: number; 10 + currentFile?: string; 11 + phase: 'validating' | 'compressing' | 'uploading' | 'creating_manifest' | 'finalizing' | 'done'; 12 + } 13 + 14 + export interface UploadJob { 15 + id: string; 16 + did: string; 17 + siteName: string; 18 + status: UploadJobStatus; 19 + progress: UploadProgress; 20 + result?: { 21 + success: boolean; 22 + uri?: string; 23 + cid?: string; 24 + fileCount?: number; 25 + siteName?: string; 26 + skippedFiles?: Array<{ name: string; reason: string }>; 27 + uploadedCount?: number; 28 + }; 29 + error?: string; 30 + createdAt: number; 31 + updatedAt: number; 32 + } 33 + 34 + // In-memory job storage 35 + const jobs = new Map<string, UploadJob>(); 36 + 37 + // SSE connections for each job 38 + const jobListeners = new Map<string, Set<(event: string, data: any) => void>>(); 39 + 40 + // Cleanup old jobs after 1 hour 41 + const JOB_TTL = 60 * 60 * 1000; 42 + 43 + export function createUploadJob(did: string, siteName: string, totalFiles: number): string { 44 + const id = crypto.randomUUID(); 45 + const now = Date.now(); 46 + 47 + const job: UploadJob = { 48 + id, 49 + did, 50 + siteName, 51 + status: 'pending', 52 + progress: { 53 + filesProcessed: 0, 54 + totalFiles, 55 + filesUploaded: 0, 56 + filesReused: 0, 57 + phase: 'validating' 58 + }, 59 + createdAt: now, 60 + updatedAt: now 61 + }; 62 + 63 + jobs.set(id, job); 64 + logger.info(`Upload job created: ${id} for ${did}/${siteName} (${totalFiles} files)`); 65 + 66 + // Schedule cleanup 67 + setTimeout(() => { 68 + jobs.delete(id); 69 + jobListeners.delete(id); 70 + logger.info(`Upload job cleaned up: ${id}`); 71 + }, JOB_TTL); 72 + 73 + return id; 74 + } 75 + 76 + export function getUploadJob(id: string): UploadJob | undefined { 77 + return jobs.get(id); 78 + } 79 + 80 + export function updateUploadJob( 81 + id: string, 82 + updates: Partial<Omit<UploadJob, 'id' | 'did' | 'siteName' | 'createdAt'>> 83 + ): void { 84 + const job = jobs.get(id); 85 + if (!job) { 86 + logger.warn(`Attempted to update non-existent job: ${id}`); 87 + return; 88 + } 89 + 90 + Object.assign(job, updates, { updatedAt: Date.now() }); 91 + jobs.set(id, job); 92 + 93 + // Notify all listeners 94 + const listeners = jobListeners.get(id); 95 + if (listeners && listeners.size > 0) { 96 + const eventData = { 97 + status: job.status, 98 + progress: job.progress, 99 + result: job.result, 100 + error: job.error 101 + }; 102 + 103 + const failedListeners: Array<(event: string, data: any) => void> = []; 104 + listeners.forEach(listener => { 105 + try { 106 + listener('progress', eventData); 107 + } catch (err) { 108 + // Client disconnected, remove this listener 109 + failedListeners.push(listener); 110 + } 111 + }); 112 + 113 + // Remove failed listeners 114 + failedListeners.forEach(listener => listeners.delete(listener)); 115 + } 116 + } 117 + 118 + export function completeUploadJob(id: string, result: UploadJob['result']): void { 119 + updateUploadJob(id, { 120 + status: 'completed', 121 + progress: { 122 + ...getUploadJob(id)!.progress, 123 + phase: 'done' 124 + }, 125 + result 126 + }); 127 + 128 + // Send final event and close connections 129 + setTimeout(() => { 130 + const listeners = jobListeners.get(id); 131 + if (listeners) { 132 + listeners.forEach(listener => { 133 + try { 134 + listener('done', result); 135 + } catch (err) { 136 + // Client already disconnected, ignore 137 + } 138 + }); 139 + jobListeners.delete(id); 140 + } 141 + }, 100); 142 + } 143 + 144 + export function failUploadJob(id: string, error: string): void { 145 + updateUploadJob(id, { 146 + status: 'failed', 147 + error 148 + }); 149 + 150 + // Send error event and close connections 151 + setTimeout(() => { 152 + const listeners = jobListeners.get(id); 153 + if (listeners) { 154 + listeners.forEach(listener => { 155 + try { 156 + listener('error', { error }); 157 + } catch (err) { 158 + // Client already disconnected, ignore 159 + } 160 + }); 161 + jobListeners.delete(id); 162 + } 163 + }, 100); 164 + } 165 + 166 + export function addJobListener(jobId: string, listener: (event: string, data: any) => void): () => void { 167 + if (!jobListeners.has(jobId)) { 168 + jobListeners.set(jobId, new Set()); 169 + } 170 + jobListeners.get(jobId)!.add(listener); 171 + 172 + // Return cleanup function 173 + return () => { 174 + const listeners = jobListeners.get(jobId); 175 + if (listeners) { 176 + listeners.delete(listener); 177 + if (listeners.size === 0) { 178 + jobListeners.delete(jobId); 179 + } 180 + } 181 + }; 182 + } 183 + 184 + export function updateJobProgress( 185 + jobId: string, 186 + progressUpdate: Partial<UploadProgress> 187 + ): void { 188 + const job = getUploadJob(jobId); 189 + if (!job) return; 190 + 191 + updateUploadJob(jobId, { 192 + progress: { 193 + ...job.progress, 194 + ...progressUpdate 195 + } 196 + }); 197 + }
+675 -305
src/routes/wisp.ts
··· 2 2 import { requireAuth, type AuthenticatedContext } from '../lib/wisp-auth' 3 3 import { NodeOAuthClient } from '@atproto/oauth-client-node' 4 4 import { Agent } from '@atproto/api' 5 + import { TID } from '@atproto/common-web' 5 6 import { 6 7 type UploadedFile, 7 8 type FileUploadResult, ··· 11 12 shouldCompressFile, 12 13 compressFile, 13 14 computeCID, 14 - extractBlobMap 15 + extractBlobMap, 16 + extractSubfsUris, 17 + findLargeDirectories, 18 + replaceDirectoryWithSubfs, 19 + estimateDirectorySize 15 20 } from '../lib/wisp-utils' 16 21 import { upsertSite } from '../lib/db' 17 22 import { logger } from '../lib/observability' 18 23 import { validateRecord } from '../lexicons/types/place/wisp/fs' 24 + import { validateRecord as validateSubfsRecord } from '../lexicons/types/place/wisp/subfs' 19 25 import { MAX_SITE_SIZE, MAX_FILE_SIZE, MAX_FILE_COUNT } from '../lib/constants' 26 + import { 27 + createUploadJob, 28 + getUploadJob, 29 + updateJobProgress, 30 + completeUploadJob, 31 + failUploadJob, 32 + addJobListener 33 + } from '../lib/upload-jobs' 20 34 21 35 function isValidSiteName(siteName: string): boolean { 22 36 if (!siteName || typeof siteName !== 'string') return false; ··· 37 51 return true; 38 52 } 39 53 54 + async function processUploadInBackground( 55 + jobId: string, 56 + agent: Agent, 57 + did: string, 58 + siteName: string, 59 + fileArray: File[] 60 + ): Promise<void> { 61 + try { 62 + // Try to fetch existing record to enable incremental updates 63 + let existingBlobMap = new Map<string, { blobRef: any; cid: string }>(); 64 + let oldSubfsUris: Array<{ uri: string; path: string }> = []; 65 + console.log('Attempting to fetch existing record...'); 66 + updateJobProgress(jobId, { phase: 'validating' }); 67 + 68 + try { 69 + const rkey = siteName; 70 + const existingRecord = await agent.com.atproto.repo.getRecord({ 71 + repo: did, 72 + collection: 'place.wisp.fs', 73 + rkey: rkey 74 + }); 75 + console.log('Existing record found!'); 76 + 77 + if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) { 78 + const manifest = existingRecord.data.value as any; 79 + 80 + // Extract blob map from main record 81 + existingBlobMap = extractBlobMap(manifest.root); 82 + console.log(`Found existing manifest with ${existingBlobMap.size} files in main record`); 83 + 84 + // Extract subfs URIs with their mount paths from main record 85 + const subfsUris = extractSubfsUris(manifest.root); 86 + oldSubfsUris = subfsUris; // Save for cleanup later 87 + 88 + if (subfsUris.length > 0) { 89 + console.log(`Found ${subfsUris.length} subfs records, fetching in parallel...`); 90 + logger.info(`Fetching ${subfsUris.length} subfs records for blob reuse`); 91 + 92 + // Fetch all subfs records in parallel 93 + const subfsRecords = await Promise.all( 94 + subfsUris.map(async ({ uri, path }) => { 95 + try { 96 + // Parse URI: at://did/collection/rkey 97 + const parts = uri.replace('at://', '').split('/'); 98 + const subDid = parts[0]; 99 + const collection = parts[1]; 100 + const subRkey = parts[2]; 101 + 102 + const record = await agent.com.atproto.repo.getRecord({ 103 + repo: subDid, 104 + collection: collection, 105 + rkey: subRkey 106 + }); 107 + 108 + return { record: record.data.value as any, mountPath: path }; 109 + } catch (err: any) { 110 + logger.warn(`Failed to fetch subfs record ${uri}: ${err?.message}`, err); 111 + return null; 112 + } 113 + }) 114 + ); 115 + 116 + // Merge blob maps from all subfs records 117 + let totalSubfsBlobs = 0; 118 + for (const subfsData of subfsRecords) { 119 + if (subfsData && subfsData.record && 'root' in subfsData.record) { 120 + // Extract blobs with the correct mount path prefix 121 + const subfsMap = extractBlobMap(subfsData.record.root, subfsData.mountPath); 122 + subfsMap.forEach((value, key) => { 123 + existingBlobMap.set(key, value); 124 + totalSubfsBlobs++; 125 + }); 126 + } 127 + } 128 + 129 + console.log(`Merged ${totalSubfsBlobs} files from ${subfsUris.length} subfs records`); 130 + logger.info(`Total blob map: ${existingBlobMap.size} files (main + subfs)`); 131 + } 132 + 133 + console.log(`Total existing blobs for reuse: ${existingBlobMap.size} files`); 134 + logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 135 + } 136 + } catch (error: any) { 137 + console.log('No existing record found or error:', error?.message || error); 138 + if (error?.status !== 400 && error?.error !== 'RecordNotFound') { 139 + logger.warn('Failed to fetch existing record, proceeding with full upload', error); 140 + } 141 + } 142 + 143 + // Convert File objects to UploadedFile format 144 + const uploadedFiles: UploadedFile[] = []; 145 + const skippedFiles: Array<{ name: string; reason: string }> = []; 146 + 147 + console.log('Processing files, count:', fileArray.length); 148 + updateJobProgress(jobId, { phase: 'compressing' }); 149 + 150 + for (let i = 0; i < fileArray.length; i++) { 151 + const file = fileArray[i]; 152 + console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes'); 153 + updateJobProgress(jobId, { 154 + filesProcessed: i + 1, 155 + currentFile: file.name 156 + }); 157 + 158 + // Skip .git directory files 159 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 160 + if (normalizedPath.startsWith('.git/') || normalizedPath === '.git') { 161 + console.log(`Skipping .git file: ${file.name}`); 162 + skippedFiles.push({ 163 + name: file.name, 164 + reason: '.git directory excluded' 165 + }); 166 + continue; 167 + } 168 + 169 + // Skip files that are too large 170 + const maxSize = MAX_FILE_SIZE; 171 + if (file.size > maxSize) { 172 + skippedFiles.push({ 173 + name: file.name, 174 + reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)` 175 + }); 176 + continue; 177 + } 178 + 179 + const arrayBuffer = await file.arrayBuffer(); 180 + const originalContent = Buffer.from(arrayBuffer); 181 + const originalMimeType = file.type || 'application/octet-stream'; 182 + 183 + // Compress and base64 encode ALL files 184 + const compressedContent = compressFile(originalContent); 185 + const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary'); 186 + const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 187 + console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 188 + logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 189 + 190 + uploadedFiles.push({ 191 + name: file.name, 192 + content: base64Content, 193 + mimeType: originalMimeType, 194 + size: base64Content.length, 195 + compressed: true, 196 + originalMimeType 197 + }); 198 + } 199 + 200 + // Update total file count after filtering (important for progress tracking) 201 + updateJobProgress(jobId, { 202 + totalFiles: uploadedFiles.length 203 + }); 204 + 205 + // Check total size limit 206 + const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 207 + const maxTotalSize = MAX_SITE_SIZE; 208 + 209 + if (totalSize > maxTotalSize) { 210 + throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 211 + } 212 + 213 + // Check file count limit 214 + if (uploadedFiles.length > MAX_FILE_COUNT) { 215 + throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`); 216 + } 217 + 218 + console.log(`After filtering: ${uploadedFiles.length} files to process (${skippedFiles.length} skipped)`); 219 + 220 + if (uploadedFiles.length === 0) { 221 + // Create empty manifest 222 + const emptyManifest = { 223 + $type: 'place.wisp.fs', 224 + site: siteName, 225 + root: { 226 + type: 'directory', 227 + entries: [] 228 + }, 229 + fileCount: 0, 230 + createdAt: new Date().toISOString() 231 + }; 232 + 233 + const validationResult = validateRecord(emptyManifest); 234 + if (!validationResult.success) { 235 + throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 236 + } 237 + 238 + const rkey = siteName; 239 + updateJobProgress(jobId, { phase: 'finalizing' }); 240 + 241 + const record = await agent.com.atproto.repo.putRecord({ 242 + repo: did, 243 + collection: 'place.wisp.fs', 244 + rkey: rkey, 245 + record: emptyManifest 246 + }); 247 + 248 + await upsertSite(did, rkey, siteName); 249 + 250 + completeUploadJob(jobId, { 251 + success: true, 252 + uri: record.data.uri, 253 + cid: record.data.cid, 254 + fileCount: 0, 255 + siteName, 256 + skippedFiles 257 + }); 258 + return; 259 + } 260 + 261 + // Process files into directory structure 262 + console.log('Processing uploaded files into directory structure...'); 263 + const validUploadedFiles = uploadedFiles.filter((f, i) => { 264 + if (!f || !f.name || !f.content) { 265 + console.error(`Filtering out invalid file at index ${i}`); 266 + return false; 267 + } 268 + return true; 269 + }); 270 + 271 + const { directory, fileCount } = processUploadedFiles(validUploadedFiles); 272 + console.log('Directory structure created, file count:', fileCount); 273 + 274 + // Upload files as blobs with retry logic for DPoP nonce conflicts 275 + console.log('Starting blob upload/reuse phase...'); 276 + updateJobProgress(jobId, { phase: 'uploading' }); 277 + 278 + // Helper function to upload blob with exponential backoff retry 279 + const uploadBlobWithRetry = async ( 280 + agent: Agent, 281 + content: Buffer, 282 + mimeType: string, 283 + fileName: string, 284 + maxRetries = 3 285 + ) => { 286 + for (let attempt = 0; attempt < maxRetries; attempt++) { 287 + try { 288 + return await agent.com.atproto.repo.uploadBlob(content, { encoding: mimeType }); 289 + } catch (error: any) { 290 + const isDPoPNonceError = 291 + error?.message?.toLowerCase().includes('nonce') || 292 + error?.message?.toLowerCase().includes('dpop') || 293 + error?.status === 409; 294 + 295 + if (isDPoPNonceError && attempt < maxRetries - 1) { 296 + const backoffMs = 100 * Math.pow(2, attempt); // 100ms, 200ms, 400ms 297 + logger.info(`[File Upload] 🔄 DPoP nonce conflict for ${fileName}, retrying in ${backoffMs}ms (attempt ${attempt + 1}/${maxRetries})`); 298 + await new Promise(resolve => setTimeout(resolve, backoffMs)); 299 + continue; 300 + } 301 + throw error; 302 + } 303 + } 304 + throw new Error(`Failed to upload ${fileName} after ${maxRetries} attempts`); 305 + }; 306 + 307 + // Use sliding window concurrency for maximum throughput 308 + const CONCURRENCY_LIMIT = 50; // Maximum concurrent uploads with retry logic 309 + const uploadedBlobs: Array<{ 310 + result: FileUploadResult; 311 + filePath: string; 312 + sentMimeType: string; 313 + returnedMimeType: string; 314 + reused: boolean; 315 + }> = []; 316 + 317 + // Process file with sliding window concurrency 318 + const processFile = async (file: UploadedFile, index: number) => { 319 + try { 320 + if (!file || !file.name) { 321 + throw new Error(`Undefined file at index ${index}`); 322 + } 323 + 324 + const fileCID = computeCID(file.content); 325 + const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 326 + const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name); 327 + 328 + if (existingBlob && existingBlob.cid === fileCID) { 329 + logger.info(`[File Upload] ♻️ Reused: ${file.name} (unchanged, CID: ${fileCID})`); 330 + updateJobProgress(jobId, { filesReused: (getUploadJob(jobId)?.progress.filesReused || 0) + 1 }); 331 + 332 + return { 333 + result: { 334 + hash: existingBlob.cid, 335 + blobRef: existingBlob.blobRef, 336 + ...(file.compressed && { 337 + encoding: 'gzip' as const, 338 + mimeType: file.originalMimeType || file.mimeType, 339 + base64: true 340 + }) 341 + }, 342 + filePath: file.name, 343 + sentMimeType: file.mimeType, 344 + returnedMimeType: existingBlob.blobRef.mimeType, 345 + reused: true 346 + }; 347 + } 348 + 349 + const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') 350 + ? 'application/octet-stream' 351 + : file.mimeType; 352 + 353 + const compressionInfo = file.compressed ? ' (gzipped)' : ''; 354 + const fileSizeMB = (file.size / 1024 / 1024).toFixed(2); 355 + logger.info(`[File Upload] ⬆️ Uploading: ${file.name} (${fileSizeMB}MB${compressionInfo})`); 356 + 357 + const uploadResult = await uploadBlobWithRetry( 358 + agent, 359 + file.content, 360 + uploadMimeType, 361 + file.name 362 + ); 363 + 364 + const returnedBlobRef = uploadResult.data.blob; 365 + updateJobProgress(jobId, { filesUploaded: (getUploadJob(jobId)?.progress.filesUploaded || 0) + 1 }); 366 + logger.info(`[File Upload] ✅ Uploaded: ${file.name} (CID: ${fileCID})`); 367 + 368 + return { 369 + result: { 370 + hash: returnedBlobRef.ref.toString(), 371 + blobRef: returnedBlobRef, 372 + ...(file.compressed && { 373 + encoding: 'gzip' as const, 374 + mimeType: file.originalMimeType || file.mimeType, 375 + base64: true 376 + }) 377 + }, 378 + filePath: file.name, 379 + sentMimeType: file.mimeType, 380 + returnedMimeType: returnedBlobRef.mimeType, 381 + reused: false 382 + }; 383 + } catch (uploadError) { 384 + logger.error('Upload failed for file', uploadError); 385 + throw uploadError; 386 + } 387 + }; 388 + 389 + // Sliding window concurrency control 390 + const processWithConcurrency = async () => { 391 + const results: any[] = []; 392 + let fileIndex = 0; 393 + const executing = new Set<Promise<void>>(); 394 + 395 + for (const file of validUploadedFiles) { 396 + const currentIndex = fileIndex++; 397 + 398 + const promise = processFile(file, currentIndex) 399 + .then(result => { 400 + results[currentIndex] = result; 401 + }) 402 + .catch(error => { 403 + logger.error(`Failed to process file at index ${currentIndex}`, error); 404 + throw error; // Re-throw to fail the entire upload 405 + }) 406 + .finally(() => { 407 + executing.delete(promise); 408 + }); 409 + 410 + executing.add(promise); 411 + 412 + if (executing.size >= CONCURRENCY_LIMIT) { 413 + await Promise.race(executing); 414 + } 415 + } 416 + 417 + // Wait for remaining uploads 418 + await Promise.all(executing); 419 + return results.filter(r => r !== undefined); // Filter out any undefined entries 420 + }; 421 + 422 + const allResults = await processWithConcurrency(); 423 + uploadedBlobs.push(...allResults); 424 + 425 + const currentReused = uploadedBlobs.filter(b => b.reused).length; 426 + const currentUploaded = uploadedBlobs.filter(b => !b.reused).length; 427 + logger.info(`[File Upload] 🎉 Upload complete → ${uploadedBlobs.length}/${validUploadedFiles.length} files (${currentUploaded} uploaded, ${currentReused} reused)`); 428 + 429 + const reusedCount = uploadedBlobs.filter(b => b.reused).length; 430 + const uploadedCount = uploadedBlobs.filter(b => !b.reused).length; 431 + logger.info(`[File Upload] 🎉 Upload phase complete! Total: ${uploadedBlobs.length} files (${uploadedCount} uploaded, ${reusedCount} reused)`); 432 + 433 + const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 434 + const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 435 + 436 + // Update directory with file blobs 437 + console.log('Updating directory with blob references...'); 438 + updateJobProgress(jobId, { phase: 'creating_manifest' }); 439 + const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 440 + 441 + // Check if we need to split into subfs records 442 + // Split proactively if we have lots of files to avoid hitting manifest size limits 443 + const MAX_MANIFEST_SIZE = 140 * 1024; // 140KB to be safe (PDS limit is 150KB) 444 + const FILE_COUNT_THRESHOLD = 250; // Start splitting early 445 + const subfsRecords: Array<{ uri: string; path: string }> = []; 446 + let workingDirectory = updatedDirectory; 447 + let currentFileCount = fileCount; 448 + 449 + // Create initial manifest to check size 450 + let manifest = createManifest(siteName, workingDirectory, fileCount); 451 + let manifestSize = JSON.stringify(manifest).length; 452 + 453 + // Split if we have lots of files OR if manifest is already too large 454 + if (fileCount >= FILE_COUNT_THRESHOLD || manifestSize > MAX_MANIFEST_SIZE) { 455 + console.log(`⚠️ Large site detected (${fileCount} files, ${(manifestSize / 1024).toFixed(1)}KB), splitting into subfs records...`); 456 + logger.info(`Large site with ${fileCount} files, splitting into subfs records`); 457 + 458 + // Keep splitting until manifest fits under limit 459 + let attempts = 0; 460 + const MAX_ATTEMPTS = 100; // Allow many splits for very large sites 461 + 462 + while (manifestSize > MAX_MANIFEST_SIZE && attempts < MAX_ATTEMPTS) { 463 + attempts++; 464 + 465 + // Find all directories sorted by size (largest first) 466 + const directories = findLargeDirectories(workingDirectory); 467 + directories.sort((a, b) => b.size - a.size); 468 + 469 + if (directories.length === 0) { 470 + // No more directories to split - this should be very rare 471 + throw new Error( 472 + `Cannot split manifest further - no subdirectories available. ` + 473 + `Current size: ${(manifestSize / 1024).toFixed(1)}KB. ` + 474 + `Try organizing files into subdirectories.` 475 + ); 476 + } 477 + 478 + // Pick the largest directory 479 + const largestDir = directories[0]; 480 + console.log(` Split #${attempts}: ${largestDir.path} (${largestDir.fileCount} files, ${(largestDir.size / 1024).toFixed(1)}KB)`); 481 + 482 + // Create a subfs record for this directory 483 + const subfsRkey = TID.nextStr(); 484 + const subfsManifest = { 485 + $type: 'place.wisp.subfs' as const, 486 + root: largestDir.directory, 487 + fileCount: largestDir.fileCount, 488 + createdAt: new Date().toISOString() 489 + }; 490 + 491 + // Validate subfs record 492 + const subfsValidation = validateSubfsRecord(subfsManifest); 493 + if (!subfsValidation.success) { 494 + throw new Error(`Invalid subfs manifest: ${subfsValidation.error?.message || 'Validation failed'}`); 495 + } 496 + 497 + // Upload subfs record to PDS 498 + const subfsRecord = await agent.com.atproto.repo.putRecord({ 499 + repo: did, 500 + collection: 'place.wisp.subfs', 501 + rkey: subfsRkey, 502 + record: subfsManifest 503 + }); 504 + 505 + const subfsUri = subfsRecord.data.uri; 506 + subfsRecords.push({ uri: subfsUri, path: largestDir.path }); 507 + console.log(` ✅ Created subfs: ${subfsUri}`); 508 + logger.info(`Created subfs record for ${largestDir.path}: ${subfsUri}`); 509 + 510 + // Replace directory with subfs node in the main tree 511 + workingDirectory = replaceDirectoryWithSubfs(workingDirectory, largestDir.path, subfsUri); 512 + 513 + // Recreate manifest and check new size 514 + currentFileCount -= largestDir.fileCount; 515 + manifest = createManifest(siteName, workingDirectory, fileCount); 516 + manifestSize = JSON.stringify(manifest).length; 517 + const newSizeKB = (manifestSize / 1024).toFixed(1); 518 + console.log(` → Manifest now ${newSizeKB}KB with ${currentFileCount} files (${subfsRecords.length} subfs total)`); 519 + 520 + // Check if we're under the limit now 521 + if (manifestSize <= MAX_MANIFEST_SIZE) { 522 + console.log(` ✅ Manifest fits! (${newSizeKB}KB < 140KB)`); 523 + break; 524 + } 525 + } 526 + 527 + if (manifestSize > MAX_MANIFEST_SIZE) { 528 + throw new Error( 529 + `Failed to fit manifest after splitting ${attempts} directories. ` + 530 + `Current size: ${(manifestSize / 1024).toFixed(1)}KB. ` + 531 + `This should never happen - please report this issue.` 532 + ); 533 + } 534 + 535 + console.log(`✅ Split complete: ${subfsRecords.length} subfs records, ${currentFileCount} files in main, ${(manifestSize / 1024).toFixed(1)}KB manifest`); 536 + logger.info(`Split into ${subfsRecords.length} subfs records, ${currentFileCount} files remaining in main tree`); 537 + } else { 538 + const manifestSizeKB = (manifestSize / 1024).toFixed(1); 539 + console.log(`Manifest created (${fileCount} files, ${manifestSizeKB}KB JSON) - no splitting needed`); 540 + } 541 + 542 + const rkey = siteName; 543 + updateJobProgress(jobId, { phase: 'finalizing' }); 544 + 545 + console.log('Putting record to PDS with rkey:', rkey); 546 + const record = await agent.com.atproto.repo.putRecord({ 547 + repo: did, 548 + collection: 'place.wisp.fs', 549 + rkey: rkey, 550 + record: manifest 551 + }); 552 + console.log('Record successfully created on PDS:', record.data.uri); 553 + 554 + // Store site in database cache 555 + await upsertSite(did, rkey, siteName); 556 + 557 + // Clean up old subfs records if we had any 558 + if (oldSubfsUris.length > 0) { 559 + console.log(`Cleaning up ${oldSubfsUris.length} old subfs records...`); 560 + logger.info(`Cleaning up ${oldSubfsUris.length} old subfs records`); 561 + 562 + // Delete old subfs records in parallel (don't wait for completion) 563 + Promise.all( 564 + oldSubfsUris.map(async ({ uri }) => { 565 + try { 566 + // Parse URI: at://did/collection/rkey 567 + const parts = uri.replace('at://', '').split('/'); 568 + const subRkey = parts[2]; 569 + 570 + await agent.com.atproto.repo.deleteRecord({ 571 + repo: did, 572 + collection: 'place.wisp.subfs', 573 + rkey: subRkey 574 + }); 575 + 576 + console.log(` 🗑️ Deleted old subfs: ${uri}`); 577 + logger.info(`Deleted old subfs record: ${uri}`); 578 + } catch (err: any) { 579 + // Don't fail the whole upload if cleanup fails 580 + console.warn(`Failed to delete old subfs ${uri}:`, err?.message); 581 + logger.warn(`Failed to delete old subfs ${uri}`, err); 582 + } 583 + }) 584 + ).catch(err => { 585 + // Log but don't fail if cleanup fails 586 + logger.warn('Some subfs cleanup operations failed', err); 587 + }); 588 + } 589 + 590 + completeUploadJob(jobId, { 591 + success: true, 592 + uri: record.data.uri, 593 + cid: record.data.cid, 594 + fileCount, 595 + siteName, 596 + skippedFiles, 597 + uploadedCount: validUploadedFiles.length 598 + }); 599 + 600 + console.log('=== UPLOAD FILES COMPLETE ==='); 601 + } catch (error) { 602 + console.error('=== UPLOAD ERROR ==='); 603 + console.error('Error details:', error); 604 + logger.error('Upload error', error); 605 + failUploadJob(jobId, error instanceof Error ? error.message : 'Unknown error'); 606 + } 607 + } 608 + 40 609 export const wispRoutes = (client: NodeOAuthClient, cookieSecret: string) => 41 610 new Elysia({ 42 611 prefix: '/wisp', ··· 49 618 const auth = await requireAuth(client, cookie) 50 619 return { auth } 51 620 }) 621 + .get( 622 + '/upload-progress/:jobId', 623 + async ({ params: { jobId }, auth, set }) => { 624 + const job = getUploadJob(jobId); 625 + 626 + if (!job) { 627 + set.status = 404; 628 + return { error: 'Job not found' }; 629 + } 630 + 631 + // Verify job belongs to authenticated user 632 + if (job.did !== auth.did) { 633 + set.status = 403; 634 + return { error: 'Unauthorized' }; 635 + } 636 + 637 + // Set up SSE headers 638 + set.headers = { 639 + 'Content-Type': 'text/event-stream', 640 + 'Cache-Control': 'no-cache', 641 + 'Connection': 'keep-alive' 642 + }; 643 + 644 + const stream = new ReadableStream({ 645 + start(controller) { 646 + const encoder = new TextEncoder(); 647 + 648 + // Send initial state 649 + const sendEvent = (event: string, data: any) => { 650 + try { 651 + const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; 652 + controller.enqueue(encoder.encode(message)); 653 + } catch (err) { 654 + // Controller closed, ignore 655 + } 656 + }; 657 + 658 + // Send keepalive comment every 15 seconds to prevent timeout 659 + const keepaliveInterval = setInterval(() => { 660 + try { 661 + controller.enqueue(encoder.encode(': keepalive\n\n')); 662 + } catch (err) { 663 + // Controller closed, stop sending keepalives 664 + clearInterval(keepaliveInterval); 665 + } 666 + }, 15000); 667 + 668 + // Send current job state immediately 669 + sendEvent('progress', { 670 + status: job.status, 671 + progress: job.progress, 672 + result: job.result, 673 + error: job.error 674 + }); 675 + 676 + // If job is already completed or failed, close the stream 677 + if (job.status === 'completed' || job.status === 'failed') { 678 + clearInterval(keepaliveInterval); 679 + controller.close(); 680 + return; 681 + } 682 + 683 + // Listen for updates 684 + const cleanup = addJobListener(jobId, (event, data) => { 685 + sendEvent(event, data); 686 + 687 + // Close stream after done or error event 688 + if (event === 'done' || event === 'error') { 689 + clearInterval(keepaliveInterval); 690 + setTimeout(() => { 691 + try { 692 + controller.close(); 693 + } catch (err) { 694 + // Already closed 695 + } 696 + }, 100); 697 + } 698 + }); 699 + 700 + // Cleanup on disconnect 701 + return () => { 702 + clearInterval(keepaliveInterval); 703 + cleanup(); 704 + }; 705 + } 706 + }); 707 + 708 + return new Response(stream); 709 + } 710 + ) 52 711 .post( 53 712 '/upload-files', 54 713 async ({ body, auth }) => { ··· 74 733 const hasFiles = files && (Array.isArray(files) ? files.length > 0 : !!files); 75 734 76 735 if (!hasFiles) { 77 - // Create agent with OAuth session 736 + // Handle empty upload synchronously (fast operation) 78 737 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 79 738 80 - // Create empty manifest 81 739 const emptyManifest = { 82 740 $type: 'place.wisp.fs', 83 741 site: siteName, ··· 89 747 createdAt: new Date().toISOString() 90 748 }; 91 749 92 - // Validate the manifest 93 750 const validationResult = validateRecord(emptyManifest); 94 751 if (!validationResult.success) { 95 752 throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 96 753 } 97 754 98 - // Use site name as rkey 99 755 const rkey = siteName; 100 756 101 757 const record = await agent.com.atproto.repo.putRecord({ ··· 116 772 }; 117 773 } 118 774 775 + // For file uploads, create a job and process in background 776 + const fileArray = Array.isArray(files) ? files : [files]; 777 + const jobId = createUploadJob(auth.did, siteName, fileArray.length); 778 + 119 779 // Create agent with OAuth session 120 780 const agent = new Agent((url, init) => auth.session.fetchHandler(url, init)) 121 781 console.log('Agent created for DID:', auth.did); 122 - 123 - // Try to fetch existing record to enable incremental updates 124 - let existingBlobMap = new Map<string, { blobRef: any; cid: string }>(); 125 - console.log('Attempting to fetch existing record...'); 126 - try { 127 - const rkey = siteName; 128 - const existingRecord = await agent.com.atproto.repo.getRecord({ 129 - repo: auth.did, 130 - collection: 'place.wisp.fs', 131 - rkey: rkey 132 - }); 133 - console.log('Existing record found!'); 134 - 135 - if (existingRecord.data.value && typeof existingRecord.data.value === 'object' && 'root' in existingRecord.data.value) { 136 - const manifest = existingRecord.data.value as any; 137 - existingBlobMap = extractBlobMap(manifest.root); 138 - console.log(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 139 - logger.info(`Found existing manifest with ${existingBlobMap.size} files for incremental update`); 140 - } 141 - } catch (error: any) { 142 - console.log('No existing record found or error:', error?.message || error); 143 - // Record doesn't exist yet, this is a new site 144 - if (error?.status !== 400 && error?.error !== 'RecordNotFound') { 145 - logger.warn('Failed to fetch existing record, proceeding with full upload', error); 146 - } 147 - } 148 - 149 - // Convert File objects to UploadedFile format 150 - // Elysia gives us File objects directly, handle both single file and array 151 - const fileArray = Array.isArray(files) ? files : [files]; 152 - const uploadedFiles: UploadedFile[] = []; 153 - const skippedFiles: Array<{ name: string; reason: string }> = []; 154 - 155 - console.log('Processing files, count:', fileArray.length); 156 - 157 - for (let i = 0; i < fileArray.length; i++) { 158 - const file = fileArray[i]; 159 - console.log(`Processing file ${i + 1}/${fileArray.length}:`, file.name, file.size, 'bytes'); 160 - 161 - // Skip files that are too large (limit to 100MB per file) 162 - const maxSize = MAX_FILE_SIZE; // 100MB 163 - if (file.size > maxSize) { 164 - skippedFiles.push({ 165 - name: file.name, 166 - reason: `file too large (${(file.size / 1024 / 1024).toFixed(2)}MB, max 100MB)` 167 - }); 168 - continue; 169 - } 170 - 171 - const arrayBuffer = await file.arrayBuffer(); 172 - const originalContent = Buffer.from(arrayBuffer); 173 - const originalMimeType = file.type || 'application/octet-stream'; 174 - 175 - // Compress and base64 encode ALL files 176 - const compressedContent = compressFile(originalContent); 177 - // Base64 encode the gzipped content to prevent PDS content sniffing 178 - // Convert base64 string to bytes using binary encoding (each char becomes exactly one byte) 179 - // This is what PDS receives and computes CID on 180 - const base64Content = Buffer.from(compressedContent.toString('base64'), 'binary'); 181 - const compressionRatio = (compressedContent.length / originalContent.length * 100).toFixed(1); 182 - console.log(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 183 - logger.info(`Compressing ${file.name}: ${originalContent.length} -> ${compressedContent.length} bytes (${compressionRatio}%), base64: ${base64Content.length} bytes`); 184 - 185 - uploadedFiles.push({ 186 - name: file.name, 187 - content: base64Content, // This is the gzipped+base64 content that will be uploaded and CID-computed 188 - mimeType: originalMimeType, 189 - size: base64Content.length, 190 - compressed: true, 191 - originalMimeType 192 - }); 193 - } 194 - 195 - // Check total size limit (300MB) 196 - const totalSize = uploadedFiles.reduce((sum, file) => sum + file.size, 0); 197 - const maxTotalSize = MAX_SITE_SIZE; // 300MB 198 - 199 - if (totalSize > maxTotalSize) { 200 - throw new Error(`Total upload size ${(totalSize / 1024 / 1024).toFixed(2)}MB exceeds 300MB limit`); 201 - } 202 - 203 - // Check file count limit (2000 files) 204 - if (uploadedFiles.length > MAX_FILE_COUNT) { 205 - throw new Error(`File count ${uploadedFiles.length} exceeds ${MAX_FILE_COUNT} files limit`); 206 - } 207 - 208 - if (uploadedFiles.length === 0) { 209 - 210 - // Create empty manifest 211 - const emptyManifest = { 212 - $type: 'place.wisp.fs', 213 - site: siteName, 214 - root: { 215 - type: 'directory', 216 - entries: [] 217 - }, 218 - fileCount: 0, 219 - createdAt: new Date().toISOString() 220 - }; 221 - 222 - // Validate the manifest 223 - const validationResult = validateRecord(emptyManifest); 224 - if (!validationResult.success) { 225 - throw new Error(`Invalid manifest: ${validationResult.error?.message || 'Validation failed'}`); 226 - } 782 + console.log('Created upload job:', jobId); 227 783 228 - // Use site name as rkey 229 - const rkey = siteName; 230 - 231 - const record = await agent.com.atproto.repo.putRecord({ 232 - repo: auth.did, 233 - collection: 'place.wisp.fs', 234 - rkey: rkey, 235 - record: emptyManifest 236 - }); 237 - 238 - await upsertSite(auth.did, rkey, siteName); 239 - 240 - return { 241 - success: true, 242 - uri: record.data.uri, 243 - cid: record.data.cid, 244 - fileCount: 0, 245 - siteName, 246 - skippedFiles, 247 - message: 'Site created but no valid web files were found to upload' 248 - }; 249 - } 250 - 251 - // Process files into directory structure 252 - console.log('Processing uploaded files into directory structure...'); 253 - console.log('uploadedFiles array length:', uploadedFiles.length); 254 - console.log('uploadedFiles contents:', uploadedFiles.map((f, i) => `${i}: ${f?.name || 'UNDEFINED'}`)); 255 - 256 - // Filter out any undefined/null/invalid entries (defensive) 257 - const validUploadedFiles = uploadedFiles.filter((f, i) => { 258 - if (!f) { 259 - console.error(`Filtering out undefined/null file at index ${i}`); 260 - return false; 261 - } 262 - if (!f.name) { 263 - console.error(`Filtering out file with no name at index ${i}:`, f); 264 - return false; 265 - } 266 - if (!f.content) { 267 - console.error(`Filtering out file with no content at index ${i}:`, f.name); 268 - return false; 269 - } 270 - return true; 784 + // Start background processing (don't await) 785 + processUploadInBackground(jobId, agent, auth.did, siteName, fileArray).catch(err => { 786 + console.error('Background upload process failed:', err); 787 + logger.error('Background upload process failed', err); 271 788 }); 272 - if (validUploadedFiles.length !== uploadedFiles.length) { 273 - console.warn(`Filtered out ${uploadedFiles.length - validUploadedFiles.length} invalid files`); 274 - } 275 - console.log('validUploadedFiles length:', validUploadedFiles.length); 276 789 277 - const { directory, fileCount } = processUploadedFiles(validUploadedFiles); 278 - console.log('Directory structure created, file count:', fileCount); 279 - 280 - // Upload files as blobs in parallel (or reuse existing blobs with matching CIDs) 281 - console.log('Starting blob upload/reuse phase...'); 282 - // For compressed files, we upload as octet-stream and store the original MIME type in metadata 283 - // For text/html files, we also use octet-stream as a workaround for PDS image pipeline issues 284 - const uploadPromises = validUploadedFiles.map(async (file, i) => { 285 - try { 286 - // Skip undefined files (shouldn't happen after filter, but defensive) 287 - if (!file || !file.name) { 288 - console.error(`ERROR: Undefined file at index ${i} in validUploadedFiles!`); 289 - throw new Error(`Undefined file at index ${i}`); 290 - } 291 - 292 - // Compute CID for this file to check if it already exists 293 - // Note: file.content is already gzipped+base64 encoded 294 - const fileCID = computeCID(file.content); 295 - 296 - // Normalize the file path for comparison (remove base folder prefix like "cobblemon/") 297 - const normalizedPath = file.name.replace(/^[^\/]*\//, ''); 298 - 299 - // Check if we have an existing blob with the same CID 300 - // Try both the normalized path and the full path 301 - const existingBlob = existingBlobMap.get(normalizedPath) || existingBlobMap.get(file.name); 302 - 303 - if (existingBlob && existingBlob.cid === fileCID) { 304 - // Reuse existing blob - no need to upload 305 - logger.info(`[File Upload] Reusing existing blob for: ${file.name} (CID: ${fileCID})`); 306 - 307 - return { 308 - result: { 309 - hash: existingBlob.cid, 310 - blobRef: existingBlob.blobRef, 311 - ...(file.compressed && { 312 - encoding: 'gzip' as const, 313 - mimeType: file.originalMimeType || file.mimeType, 314 - base64: true 315 - }) 316 - }, 317 - filePath: file.name, 318 - sentMimeType: file.mimeType, 319 - returnedMimeType: existingBlob.blobRef.mimeType, 320 - reused: true 321 - }; 322 - } 323 - 324 - // File is new or changed - upload it 325 - // If compressed, always upload as octet-stream 326 - // Otherwise, workaround: PDS incorrectly processes text/html through image pipeline 327 - const uploadMimeType = file.compressed || file.mimeType.startsWith('text/html') 328 - ? 'application/octet-stream' 329 - : file.mimeType; 330 - 331 - const compressionInfo = file.compressed ? ' (gzipped)' : ''; 332 - logger.info(`[File Upload] Uploading new/changed file: ${file.name} (original: ${file.mimeType}, sending as: ${uploadMimeType}, ${file.size} bytes${compressionInfo}, CID: ${fileCID})`); 333 - 334 - const uploadResult = await agent.com.atproto.repo.uploadBlob( 335 - file.content, 336 - { 337 - encoding: uploadMimeType 338 - } 339 - ); 340 - 341 - const returnedBlobRef = uploadResult.data.blob; 342 - 343 - // Use the blob ref exactly as returned from PDS 344 - return { 345 - result: { 346 - hash: returnedBlobRef.ref.toString(), 347 - blobRef: returnedBlobRef, 348 - ...(file.compressed && { 349 - encoding: 'gzip' as const, 350 - mimeType: file.originalMimeType || file.mimeType, 351 - base64: true 352 - }) 353 - }, 354 - filePath: file.name, 355 - sentMimeType: file.mimeType, 356 - returnedMimeType: returnedBlobRef.mimeType, 357 - reused: false 358 - }; 359 - } catch (uploadError) { 360 - logger.error('Upload failed for file', uploadError); 361 - throw uploadError; 362 - } 363 - }); 364 - 365 - // Wait for all uploads to complete 366 - const uploadedBlobs = await Promise.all(uploadPromises); 367 - 368 - // Count reused vs uploaded blobs 369 - const reusedCount = uploadedBlobs.filter(b => (b as any).reused).length; 370 - const uploadedCount = uploadedBlobs.filter(b => !(b as any).reused).length; 371 - console.log(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 372 - logger.info(`Blob statistics: ${reusedCount} reused, ${uploadedCount} uploaded, ${uploadedBlobs.length} total`); 373 - 374 - // Extract results and file paths in correct order 375 - const uploadResults: FileUploadResult[] = uploadedBlobs.map(blob => blob.result); 376 - const filePaths: string[] = uploadedBlobs.map(blob => blob.filePath); 377 - 378 - // Update directory with file blobs 379 - console.log('Updating directory with blob references...'); 380 - const updatedDirectory = updateFileBlobs(directory, uploadResults, filePaths); 381 - 382 - // Create manifest 383 - console.log('Creating manifest...'); 384 - const manifest = createManifest(siteName, updatedDirectory, fileCount); 385 - console.log('Manifest created successfully'); 386 - 387 - // Use site name as rkey 388 - const rkey = siteName; 389 - 390 - let record; 391 - try { 392 - console.log('Putting record to PDS with rkey:', rkey); 393 - record = await agent.com.atproto.repo.putRecord({ 394 - repo: auth.did, 395 - collection: 'place.wisp.fs', 396 - rkey: rkey, 397 - record: manifest 398 - }); 399 - console.log('Record successfully created on PDS:', record.data.uri); 400 - } catch (putRecordError: any) { 401 - console.error('FAILED to create record on PDS:', putRecordError); 402 - logger.error('Failed to create record on PDS', putRecordError); 403 - 404 - throw putRecordError; 405 - } 406 - 407 - // Store site in database cache 408 - await upsertSite(auth.did, rkey, siteName); 409 - 410 - const result = { 790 + // Return immediately with job ID 791 + return { 411 792 success: true, 412 - uri: record.data.uri, 413 - cid: record.data.cid, 414 - fileCount, 415 - siteName, 416 - skippedFiles, 417 - uploadedCount: validUploadedFiles.length 793 + jobId, 794 + message: 'Upload started. Connect to /wisp/upload-progress/' + jobId + ' for progress updates.' 418 795 }; 419 - 420 - console.log('=== UPLOAD FILES COMPLETE ==='); 421 - return result; 422 796 } catch (error) { 423 797 console.error('=== UPLOAD ERROR ==='); 424 798 console.error('Error details:', error); 425 - console.error('Stack trace:', error instanceof Error ? error.stack : 'N/A'); 426 - logger.error('Upload error', error, { 427 - message: error instanceof Error ? error.message : 'Unknown error', 428 - name: error instanceof Error ? error.name : undefined 429 - }); 799 + logger.error('Upload error', error); 430 800 throw new Error(`Failed to upload files: ${error instanceof Error ? error.message : 'Unknown error'}`); 431 801 } 432 802 }