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 phase: 'validating' | 'compressing' | 'uploading' | 'creating_manifest' | 'finalizing' | 'done';
12}
13
14export 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
35const jobs = new Map<string, UploadJob>();
36
37// SSE connections for each job
38const jobListeners = new Map<string, Set<(event: string, data: any) => void>>();
39
40// Cleanup old jobs after 1 hour
41const JOB_TTL = 60 * 60 * 1000;
42
43export 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
76export function getUploadJob(id: string): UploadJob | undefined {
77 return jobs.get(id);
78}
79
80export 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
118export 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
144export 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
166export 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
184export 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}