1
28
29import { filesApi, type FileDetail, type FileStatus } from "./api";
30
31
32
33export type UploadPhase =
34 | "precheck"
35 | "uploading"
36 | "completing"
37 | "processing"
38 | "ready"
39 | "error"
40 | "cancelled";
41
42export interface UploadProgress {
43 phase: UploadPhase;
44
45 fraction: number;
46 loaded: number;
47 total: number;
48
49 speedBps: number;
50}
51
52export interface UploadResult {
53
54 file: FileDetail;
55
56 stillProcessing: boolean;
57}
58
59export interface UploadOptions {
60 pageId?: string | null;
61 blockId?: string | null;
62
63 replaceFileId?: string;
64 workspaceId?: string;
65
66 maxFileSize: number;
67 onProgress?: (p: UploadProgress) => void;
68
73 onSession?: (fileId: string) => void;
74
75 poll?: { intervalMs?: number; timeoutMs?: number; get?: (fileId: string, workspaceId?: string) => Promise<FileDetail> };
76}
77
78
79export class UploadError extends Error {
80 code: string;
81 status?: number;
82
83 fileId?: string;
84 constructor(code: string, message: string, status?: number, fileId?: string) {
85 super(message);
86 this.name = "UploadError";
87 this.code = code;
88 this.status = status;
89 this.fileId = fileId;
90 }
91}
92
93export interface UploadHandle {
94
95 readonly fileId: string | null;
96 promise: Promise<UploadResult>;
97
98 cancel: () => void;
99}
100
101
102
103
104export interface XhrLike {
105 open(method: string, url: string): void;
106 setRequestHeader(name: string, value: string): void;
107 upload: {
108 addEventListener(type: "progress", cb: (e: { loaded: number; total: number }) => void): void;
109 };
110 send(body: Blob): void;
111 abort(): void;
112 status: number;
113 responseText: string;
114 onload: (() => void) | null;
115 onerror: (() => void) | null;
116 onabort: (() => void) | null;
117}
118
119export type XhrFactory = () => XhrLike;
120
121const defaultXhr: XhrFactory = () => new globalThis.XMLHttpRequest() as unknown as XhrLike;
122
123function parseApiError(status: number, text: string): UploadError {
124 let code = "UPLOAD_FAILED";
125 let message = `Upload failed (HTTP ${status})`;
126 try {
127 const body = JSON.parse(text) as { error?: { code?: string; message?: string } };
128 if (body.error?.code) code = body.error.code;
129 if (body.error?.message) message = body.error.message;
130 } catch {
131
132 }
133 return new UploadError(code, message, status);
134}
135
136
137
138export function uploadFile(file: File, options: UploadOptions, xhrFactory: XhrFactory = defaultXhr): UploadHandle {
139 let cancelled = false;
140 let xhr: XhrLike | null = null;
141 let pollTimer: ReturnType<typeof setTimeout> | null = null;
142 let fileRowId: string | null = null;
143 let completed = false;
144 let sessionSeen = false;
145
146 const notify = (p: UploadProgress) => options.onProgress?.(p);
147
148 const rejectCancelled = () => {
149 const err = new UploadError("CANCELLED", "Upload cancelled.", undefined, fileRowId ?? undefined);
150 err.name = "UploadError";
151 return err;
152 };
153
154 const openSession = async (): Promise<{
155 uploadUrl: string;
156 headers: Record<string, string>;
157 fileId: string;
158 }> => {
159 const session = await filesApi.openSession({
160 name: file.name,
161 mimeType: file.type || "application/octet-stream",
162 size: file.size,
163 pageId: options.pageId ?? null,
164 blockId: options.blockId ?? null,
165 replaceFileId: options.replaceFileId,
166 workspaceId: options.workspaceId,
167 });
168 fileRowId = session.file.id;
169
170
171 if (!sessionSeen) {
172 sessionSeen = true;
173 options.onSession?.(session.file.id);
174 }
175 return { uploadUrl: session.uploadUrl, headers: session.headers, fileId: session.file.id };
176 };
177
178
179 const putOnce = (session: { uploadUrl: string; headers: Record<string, string> }): Promise<void> =>
180 new Promise((resolve, reject) => {
181 const x = xhrFactory();
182 xhr = x;
183 let lastLoaded = 0;
184 let lastTime = 0;
185 x.open("PUT", session.uploadUrl);
186 for (const [name, value] of Object.entries(session.headers)) x.setRequestHeader(name, value);
187 x.upload.addEventListener("progress", (e) => {
188 if (cancelled) return;
189 const now = Date.now();
190 const speedBps =
191 lastTime > 0 && now > lastTime ? Math.max(0, e.loaded - lastLoaded) / ((now - lastTime) / 1000) : 0;
192 lastLoaded = e.loaded;
193 lastTime = now;
194
195
196 notify({
197 phase: "uploading",
198 loaded: e.loaded,
199 total: Math.max(e.total, file.size),
200 fraction: Math.min(e.loaded / Math.max(e.total, file.size), 1),
201 speedBps,
202 });
203 });
204 x.onload = () => {
205 xhr = null;
206 if (cancelled) return reject(rejectCancelled());
207 if (x.status >= 200 && x.status < 300) return resolve();
208 reject(parseApiError(x.status, x.responseText));
209 };
210 x.onerror = () => {
211 xhr = null;
212 reject(cancelled ? rejectCancelled() : new UploadError("NETWORK_ERROR", "Network error during upload."));
213 };
214 x.onabort = () => {
215 xhr = null;
216 reject(rejectCancelled());
217 };
218 x.send(file);
219 });
220
221 const putWithRetry = async (): Promise<void> => {
222 try {
223 const session = await openSession();
224 await putOnce(session);
225 notify({ phase: "completing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
226 } catch (err) {
227
228
229 if (err instanceof UploadError && err.code !== "CANCELLED" && err.status === 403) {
230 const retry = await openSession();
231 await putOnce(retry);
232 notify({ phase: "completing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
233 return;
234 }
235 throw err;
236 }
237 };
238
239
240 const poll = async (fileId: string): Promise<FileDetail> => {
241 const intervalMs = options.poll?.intervalMs ?? 2000;
242 const timeoutMs = options.poll?.timeoutMs ?? 60_000;
243 const get = options.poll?.get ?? ((id: string, ws?: string) => filesApi.get(id, { workspaceId: ws }));
244 const started = Date.now();
245 let last: FileDetail | null = null;
246 for (;;) {
247 if (cancelled) throw rejectCancelled();
248 last = await get(fileId, options.workspaceId);
249 if (last.status !== "UPLOADING" && last.status !== "SCANNING") return last;
250 if (Date.now() - started >= timeoutMs) return last;
251 await new Promise<void>((resolve) => {
252 pollTimer = setTimeout(resolve, intervalMs);
253 });
254 if (cancelled && pollTimer) clearTimeout(pollTimer);
255 }
256 };
257
258 const promise = (async (): Promise<UploadResult> => {
259
260 if (file.size > options.maxFileSize) {
261 const limit = Math.floor(options.maxFileSize / (1024 * 1024));
262 throw new UploadError("FILE_TOO_LARGE", `${file.name} is larger than the ${limit} MB per-file limit on your plan.`);
263 }
264
265 await putWithRetry();
266 completed = true;
267 const done = await filesApi.complete(fileRowId!, { workspaceId: options.workspaceId });
268 notify({ phase: "processing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
269
270 const detail = await poll(done.id);
271 if (detail.status === "READY") {
272 notify({ phase: "ready", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
273 return { file: detail, stillProcessing: false };
274 }
275 if (detail.status === "VIRUS_DETECTED" || detail.status === "FAILED") {
276 notify({ phase: "error", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
277 throw new UploadError(detail.status, detail.status === "VIRUS_DETECTED" ? "Blocked by virus scan." : "Processing failed.", undefined, detail.id);
278 }
279
280 notify({ phase: "processing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 });
281 return { file: detail, stillProcessing: true };
282 })();
283
284
285 void promise.catch(() => undefined);
286
287 const cancel = () => {
288 if (cancelled) return;
289 cancelled = true;
290 if (pollTimer) clearTimeout(pollTimer);
291 if (xhr) xhr.abort();
292
293
294
295 if (fileRowId && !completed) {
296 void filesApi.cancel(fileRowId, { workspaceId: options.workspaceId }).catch(() => undefined);
297 }
298 };
299
300 return {
301 get fileId() {
302 return fileRowId;
303 },
304 promise,
305 cancel,
306 };
307}
308
309
314export const isTerminalStatus = (status: FileStatus): boolean =>
315 status === "READY" || status === "VIRUS_DETECTED" || status === "FAILED";
316