Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
1import { createLogger } from '@wisp/observability'; 2 3const logger = createLogger('main-app'); 4 5export type UploadJobStatus = 'pending' | 'processing' | 'uploading' | 'completed' | 'failed'; 6 7export interface UploadProgress { 8 filesProcessed: number; 9 totalFiles: number; 10 filesUploaded: number; 11 filesReused: number; 12 currentFile?: string; 13 currentFileStatus?: 'checking' | 'uploading' | 'uploaded' | 'reused' | 'failed'; 14 phase: 'validating' | 'compressing' | 'uploading' | 'creating_manifest' | 'finalizing' | 'done'; 15} 16 17export interface UploadJob { 18 id: string; 19 did: string; 20 siteName: string; 21 status: UploadJobStatus; 22 progress: UploadProgress; 23 result?: { 24 success: boolean; 25 uri?: string; 26 cid?: string; 27 fileCount?: number; 28 siteName?: string; 29 skippedFiles?: Array<{ name: string; reason: string }>; 30 failedFiles?: Array<{ name: string; index: number; error: string; size: number }>; 31 uploadedCount?: number; 32 hasFailures?: boolean; 33 }; 34 error?: string; 35 createdAt: number; 36 updatedAt: number; 37} 38 39// In-memory job storage 40const jobs = new Map<string, UploadJob>(); 41 42// SSE connections for each job 43const jobListeners = new Map<string, Set<(event: string, data: any) => void>>(); 44 45// Cleanup old jobs after 1 hour 46const JOB_TTL = 60 * 60 * 1000; 47 48export function createUploadJob(did: string, siteName: string, totalFiles: number): string { 49 const id = crypto.randomUUID(); 50 const now = Date.now(); 51 52 const job: UploadJob = { 53 id, 54 did, 55 siteName, 56 status: 'pending', 57 progress: { 58 filesProcessed: 0, 59 totalFiles, 60 filesUploaded: 0, 61 filesReused: 0, 62 phase: 'validating' 63 }, 64 createdAt: now, 65 updatedAt: now 66 }; 67 68 jobs.set(id, job); 69 logger.info(`Upload job created: ${id} for ${did}/${siteName} (${totalFiles} files)`); 70 71 // Schedule cleanup 72 setTimeout(() => { 73 jobs.delete(id); 74 jobListeners.delete(id); 75 logger.info(`Upload job cleaned up: ${id}`); 76 }, JOB_TTL); 77 78 return id; 79} 80 81export function getUploadJob(id: string): UploadJob | undefined { 82 return jobs.get(id); 83} 84 85export function updateUploadJob( 86 id: string, 87 updates: Partial<Omit<UploadJob, 'id' | 'did' | 'siteName' | 'createdAt'>> 88): void { 89 const job = jobs.get(id); 90 if (!job) { 91 logger.warn(`Attempted to update non-existent job: ${id}`); 92 return; 93 } 94 95 Object.assign(job, updates, { updatedAt: Date.now() }); 96 jobs.set(id, job); 97 98 // Notify all listeners 99 const listeners = jobListeners.get(id); 100 if (listeners && listeners.size > 0) { 101 const eventData = { 102 status: job.status, 103 progress: job.progress, 104 result: job.result, 105 error: job.error 106 }; 107 108 const failedListeners: Array<(event: string, data: any) => void> = []; 109 listeners.forEach(listener => { 110 try { 111 listener('progress', eventData); 112 } catch (err) { 113 // Client disconnected, remove this listener 114 failedListeners.push(listener); 115 } 116 }); 117 118 // Remove failed listeners 119 failedListeners.forEach(listener => listeners.delete(listener)); 120 } 121} 122 123export function completeUploadJob(id: string, result: UploadJob['result']): void { 124 updateUploadJob(id, { 125 status: 'completed', 126 progress: { 127 ...getUploadJob(id)!.progress, 128 phase: 'done' 129 }, 130 result 131 }); 132 133 // Send final event and close connections 134 setTimeout(() => { 135 const listeners = jobListeners.get(id); 136 if (listeners) { 137 listeners.forEach(listener => { 138 try { 139 listener('done', result); 140 } catch (err) { 141 // Client already disconnected, ignore 142 } 143 }); 144 jobListeners.delete(id); 145 } 146 }, 100); 147} 148 149export function failUploadJob(id: string, error: string): void { 150 updateUploadJob(id, { 151 status: 'failed', 152 error 153 }); 154 155 // Send error event and close connections 156 setTimeout(() => { 157 const listeners = jobListeners.get(id); 158 if (listeners) { 159 listeners.forEach(listener => { 160 try { 161 listener('error', { error }); 162 } catch (err) { 163 // Client already disconnected, ignore 164 } 165 }); 166 jobListeners.delete(id); 167 } 168 }, 100); 169} 170 171export function addJobListener(jobId: string, listener: (event: string, data: any) => void): () => void { 172 if (!jobListeners.has(jobId)) { 173 jobListeners.set(jobId, new Set()); 174 } 175 jobListeners.get(jobId)!.add(listener); 176 177 // Return cleanup function 178 return () => { 179 const listeners = jobListeners.get(jobId); 180 if (listeners) { 181 listeners.delete(listener); 182 if (listeners.size === 0) { 183 jobListeners.delete(jobId); 184 } 185 } 186 }; 187} 188 189export function updateJobProgress( 190 jobId: string, 191 progressUpdate: Partial<UploadProgress> 192): void { 193 const job = getUploadJob(jobId); 194 if (!job) return; 195 196 updateUploadJob(jobId, { 197 progress: { 198 ...job.progress, 199 ...progressUpdate 200 } 201 }); 202}