From bde00e2d9f7ab2777025a172588fc0bf39c3c001 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 30 Apr 2026 02:11:43 +0200 Subject: [PATCH] feat(web): transferStore upload + resume via tus-js-client --- packages/web/src/stores/transferStore.test.ts | 19 +- packages/web/src/stores/transferStore.ts | 181 ++++++++++++++++++ .../src/stores/transferStore.upload.test.ts | 163 ++++++++++++++++ 3 files changed, 362 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/stores/transferStore.upload.test.ts diff --git a/packages/web/src/stores/transferStore.test.ts b/packages/web/src/stores/transferStore.test.ts index bc55706c..533aa87e 100644 --- a/packages/web/src/stores/transferStore.test.ts +++ b/packages/web/src/stores/transferStore.test.ts @@ -1,5 +1,22 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import 'fake-indexeddb/auto'; + +// Stub authStore so transferStore's import chain doesn't pull in AudioManager (which needs AudioWorkletNode). +vi.mock('./authStore', () => ({ + useAuthStore: { + getState: () => ({ token: null, user: null }), + }, +})); + +// Stub tus-js-client — these basic-store tests don't exercise the upload path. +vi.mock('tus-js-client', () => ({ + Upload: class MockUpload { + constructor(_file: unknown, _opts: unknown) {} + start() {} + abort() { return Promise.resolve(); } + }, +})); + import { useTransferStore } from './transferStore'; describe('transferStore basics', () => { diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index 580fcd94..eccc3f02 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -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; + abortUpload: (id: string) => void; + pauseUpload: (id: string) => void; + resumeUpload: (id: string) => Promise; + 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(); + // Custom storage that serializes Map as an array of entries. // Only the `transfers` slice is persisted; partialize controls which entries. const mapAwareStorage: PersistStorage> = { @@ -173,6 +184,176 @@ export const useTransferStore = create()( 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 }; + 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) => diff --git a/packages/web/src/stores/transferStore.upload.test.ts b/packages/web/src/stores/transferStore.upload.test.ts new file mode 100644 index 00000000..1d592910 --- /dev/null +++ b/packages/web/src/stores/transferStore.upload.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; + +// We mock tus-js-client to drive lifecycle synchronously without real network. +const startMock = vi.fn(); +const abortMock = vi.fn().mockResolvedValue(undefined); + +vi.mock('tus-js-client', () => { + class MockUpload { + private opts: any; + public url: string | null = null; + constructor(_file: File, opts: any) { + this.opts = opts; + } + start() { + startMock(); + // 1. Simulate POST → Location returned. + this.opts.onAfterResponse?.({}, { + getHeader: (h: string) => + h === 'Location' ? '/api/files/abc-123' : + h === 'Upload-Expires' ? new Date(Date.now() + 60_000).toUTCString() : + undefined, + }); + // 2. Simulate progress. + this.opts.onProgress?.(50, 100); + // 3. Simulate success with a body containing Attachment JSON. + this.opts.onSuccess?.({ + lastResponse: { + getBody: () => JSON.stringify({ + id: 'att-9', + filename: 'a.png', + originalName: 'a.png', + mimetype: 'image/png', + size: 100, + thumbnailFilename: null, + width: null, + height: null, + duration: null, + messageId: '', + }), + }, + }); + } + abort(_terminate?: boolean) { + return abortMock(); + } + } + return { Upload: MockUpload }; +}); + +// Authstore mock: provide token + user. +vi.mock('./authStore', () => ({ + useAuthStore: { + getState: () => ({ + token: 'test-token-12345', + user: { id: 'u-9', username: 'tester' }, + }), + }, +})); + +import { useTransferStore } from './transferStore'; + +describe('transferStore.startUpload', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + startMock.mockClear(); + abortMock.mockClear(); + if (typeof localStorage !== 'undefined') localStorage.clear(); + }); + + it('drives a transfer through tus → completed with attachmentId', async () => { + const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' }); + const id = await useTransferStore.getState().startUpload(file, { channelId: 'ch-1', tray: true }); + expect(startMock).toHaveBeenCalledTimes(1); + const t = useTransferStore.getState().get(id); + expect(t).toBeDefined(); + expect(t!.state).toBe('completed'); + expect(t!.attachmentId).toBe('att-9'); + expect(t!.tusUploadUrl).toBe('/api/files/abc-123'); + expect(t!.tusExpiresAt).toBeGreaterThan(Date.now()); + }); + + it('captures Upload-Expires timestamp on the first response', async () => { + const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' }); + const id = await useTransferStore.getState().startUpload(file, { tray: true }); + const t = useTransferStore.getState().get(id)!; + // Should be approximately 60s from now (we set Date+60s in the mock) + expect(t.tusExpiresAt).toBeGreaterThan(Date.now() + 50_000); + expect(t.tusExpiresAt).toBeLessThanOrEqual(Date.now() + 70_000); + }); + + it('throws when not authenticated', async () => { + // Override the authStore mock for this test only + const file = new File([new Uint8Array(10)], 'a.png', { type: 'image/png' }); + const { useAuthStore } = await import('./authStore'); + const orig = useAuthStore.getState; + (useAuthStore as any).getState = () => ({ token: null, user: null }); + await expect(useTransferStore.getState().startUpload(file, { tray: true })).rejects.toThrow(/not authenticated/i); + (useAuthStore as any).getState = orig; + }); + + it('abortUpload calls Upload.abort(true) and sets state aborted', async () => { + const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' }); + const id = await useTransferStore.getState().startUpload(file, { tray: true }); + // After mock-driven success, the live upload is removed; abort the (now-completed) transfer should still flip state. + useTransferStore.getState().abortUpload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('aborted'); + }); + + it('pauseUpload sets state paused', async () => { + const file = new File([new Uint8Array(100)], 'a.png', { type: 'image/png' }); + const id = await useTransferStore.getState().startUpload(file, { tray: true }); + useTransferStore.getState().pauseUpload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('paused'); + }); +}); + +describe('transferStore.resumeUpload', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + startMock.mockClear(); + abortMock.mockClear(); + }); + + it('returns early when transfer has no tusUploadUrl', async () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setState_(id, 'paused'); + await useTransferStore.getState().resumeUpload(id); + const t = useTransferStore.getState().get(id)!; + expect(t.state).toBe('failed'); + expect(t.error?.message).toMatch(/no tus url/i); + }); + + it('returns failed when tus URL has expired', async () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + }); + useTransferStore.getState().setTusUrl(id, '/api/files/expired', Date.now() - 1000); + useTransferStore.getState().setState_(id, 'paused'); + await useTransferStore.getState().resumeUpload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('failed'); + expect(useTransferStore.getState().get(id)!.error?.message).toMatch(/expired/i); + }); + + it('stays paused when no FS handle is available (re-pick required)', async () => { + const id = useTransferStore.getState().createTransfer({ + type: 'upload', + file: { name: 'a.png', size: 100, mimetype: 'image/png' }, + tray: true, + // No fileHandleId — no handle stored + }); + useTransferStore.getState().setTusUrl(id, '/api/files/abc', Date.now() + 60_000); + useTransferStore.getState().setState_(id, 'paused'); + await useTransferStore.getState().resumeUpload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('paused'); + }); +});