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}