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}