feat(web): transferStore upload + resume via tus-js-client

This commit is contained in:
Jannis Braun
2026-04-30 02:11:43 +02:00
parent bdfe7b453a
commit bde00e2d9f
3 changed files with 362 additions and 1 deletions
+181
View File
@@ -1,5 +1,8 @@
import { create } from 'zustand';
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
import { Upload, type UploadOptions } from 'tus-js-client';
import type { Attachment } from '@backspace/shared';
import { useAuthStore } from './authStore';
export type TransferType = 'upload' | 'download';
export type TransferState =
@@ -59,6 +62,11 @@ interface TransferStoreActions {
setAttachmentId: (id: string, attachmentId: string) => void;
remove: (id: string) => void;
startUpload: (file: Blob, opts: { channelId?: string; tray?: boolean; origin?: string; fileHandleId?: string }) => Promise<string>;
abortUpload: (id: string) => void;
pauseUpload: (id: string) => void;
resumeUpload: (id: string) => Promise<void>;
get: (id: string) => Transfer | undefined;
listVisible: () => Transfer[];
listForChannel: (channelId: string) => Transfer[];
@@ -70,6 +78,9 @@ function uuid(): string {
return crypto.randomUUID();
}
// Live tus Upload instances — keyed by transferId. Not serializable, never persisted.
const liveUploads = new Map<string, Upload>();
// Custom storage that serializes Map<string, Transfer> as an array of entries.
// Only the `transfers` slice is persisted; partialize controls which entries.
const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
@@ -173,6 +184,176 @@ export const useTransferStore = create<TransferStore>()(
return { transfers: next };
}),
startUpload: async (file, opts) => {
const token = useAuthStore.getState().token;
const user = useAuthStore.getState().user;
if (!token) throw new Error('Cannot start upload — not authenticated');
const baseOrigin = opts.origin ?? '';
const endpoint = `${baseOrigin}/api/files/`;
const fileLike = file instanceof File
? { name: file.name, size: file.size, mimetype: file.type || 'application/octet-stream' }
: { name: 'upload', size: file.size, mimetype: (file as Blob).type || 'application/octet-stream' };
const id = get().createTransfer({
type: 'upload',
file: fileLike,
tray: opts.tray ?? true,
channelId: opts.channelId,
origin: opts.origin,
fileHandleId: opts.fileHandleId,
uploaderUserId: user?.id,
});
const tusOpts: UploadOptions = {
endpoint,
retryDelays: [0, 1000, 3000, 5000, 10_000],
metadata: {
filename: fileLike.name,
filetype: fileLike.mimetype,
},
chunkSize: 5 * 1024 * 1024,
headers: { Authorization: `Bearer ${token}` },
onProgress: (loaded: number) => {
get().updateProgress(id, loaded);
const t = get().get(id);
if (t?.state === 'queued') get().setState_(id, 'active');
},
onAfterResponse: (_req, res) => {
// res.getHeader returns string | undefined per tus-js-client v4 types.
const location = res.getHeader('Location');
const expires = res.getHeader('Upload-Expires');
if (location && !get().get(id)?.tusUploadUrl) {
const expiresMs = expires ? new Date(expires).getTime() : Date.now() + 24 * 60 * 60 * 1000;
get().setTusUrl(id, location, expiresMs);
}
},
onSuccess: (payload) => {
try {
const body = payload.lastResponse?.getBody?.() ?? '';
const att = JSON.parse(body) as Attachment;
get().setAttachmentId(id, att.id);
get().setState_(id, 'completed');
get().updateProgress(id, fileLike.size);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Could not parse upload response';
get().setError(id, { message: msg, permanent: true });
} finally {
liveUploads.delete(id);
}
},
onError: (err: Error) => {
const msg = err.message ?? 'Upload error';
// Treat 4xx as permanent (no retry past tus's own retry chain).
const permanent = /\b4\d\d\b/.test(msg);
get().setError(id, { message: msg, permanent });
liveUploads.delete(id);
},
};
const upload = new Upload(file as File, tusOpts);
liveUploads.set(id, upload);
upload.start();
return id;
},
abortUpload: (id) => {
const u = liveUploads.get(id);
if (u) {
// tus-js-client v4: abort(true) deletes server-side state via DELETE.
// We pass true so the user actually frees the slot, not just "pause".
void u.abort(true).catch(() => { /* server may be unreachable; that's OK */ });
liveUploads.delete(id);
}
get().setState_(id, 'aborted');
},
pauseUpload: (id) => {
const u = liveUploads.get(id);
if (u) {
// abort(false) keeps server-side state — resumable.
void u.abort(false).catch(() => { /* ignore */ });
liveUploads.delete(id);
}
get().setState_(id, 'paused');
},
resumeUpload: async (id) => {
const t = get().get(id);
if (!t || t.type !== 'upload') return;
if (!t.tusUploadUrl) {
get().setError(id, { message: 'No tus URL — cannot resume', permanent: true });
return;
}
if (Date.now() > (t.tusExpiresAt ?? 0)) {
get().setError(id, { message: 'Upload expired', permanent: true });
return;
}
const token = useAuthStore.getState().token;
if (!token) {
get().setError(id, { message: 'Not authenticated', permanent: true });
return;
}
// Try to reacquire the file via the persisted FileSystemFileHandle.
let blob: Blob | undefined;
if (t.fileHandleId) {
const { getHandle, ensurePermission } = await import('../utils/idbHandles');
const handle = await getHandle(t.fileHandleId);
if (handle) {
const perm = await ensurePermission(handle, 'read');
if (perm === 'granted') {
const handleAny = handle as unknown as { getFile?: () => Promise<File> };
if (typeof handleAny.getFile === 'function') {
blob = await handleAny.getFile();
}
}
}
}
if (!blob) {
// No handle path — UI surfaces "re-pick to resume". Stay paused.
get().setState_(id, 'paused');
return;
}
const upload = new Upload(blob as File, {
endpoint: t.origin ? `${t.origin}/api/files/` : '/api/files/',
uploadUrl: t.tusUploadUrl,
retryDelays: [0, 1000, 3000, 5000, 10_000],
chunkSize: 5 * 1024 * 1024,
headers: { Authorization: `Bearer ${token}` },
onProgress: (loaded: number) => {
get().updateProgress(id, loaded);
const cur = get().get(id);
if (cur?.state !== 'active') get().setState_(id, 'active');
},
onSuccess: (payload) => {
try {
const body = payload.lastResponse?.getBody?.() ?? '';
const att = JSON.parse(body) as Attachment;
get().setAttachmentId(id, att.id);
get().setState_(id, 'completed');
} catch (e) {
const msg = e instanceof Error ? e.message : 'Resume completed but parse failed';
get().setError(id, { message: msg, permanent: true });
} finally {
liveUploads.delete(id);
}
},
onError: (err: Error) => {
const msg = err.message ?? 'Resume error';
const permanent = /\b4\d\d\b/.test(msg);
get().setError(id, { message: msg, permanent });
liveUploads.delete(id);
},
});
liveUploads.set(id, upload);
upload.start();
},
get: (id) => get().transfers.get(id),
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
listForChannel: (channelId) =>