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