feat(web): transferStore upload + resume via tus-js-client
This commit is contained in:
@@ -1,5 +1,22 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
import 'fake-indexeddb/auto';
|
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';
|
import { useTransferStore } from './transferStore';
|
||||||
|
|
||||||
describe('transferStore basics', () => {
|
describe('transferStore basics', () => {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
|
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 TransferType = 'upload' | 'download';
|
||||||
export type TransferState =
|
export type TransferState =
|
||||||
@@ -59,6 +62,11 @@ interface TransferStoreActions {
|
|||||||
setAttachmentId: (id: string, attachmentId: string) => void;
|
setAttachmentId: (id: string, attachmentId: string) => void;
|
||||||
remove: (id: 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;
|
get: (id: string) => Transfer | undefined;
|
||||||
listVisible: () => Transfer[];
|
listVisible: () => Transfer[];
|
||||||
listForChannel: (channelId: string) => Transfer[];
|
listForChannel: (channelId: string) => Transfer[];
|
||||||
@@ -70,6 +78,9 @@ function uuid(): string {
|
|||||||
return crypto.randomUUID();
|
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.
|
// Custom storage that serializes Map<string, Transfer> as an array of entries.
|
||||||
// Only the `transfers` slice is persisted; partialize controls which entries.
|
// Only the `transfers` slice is persisted; partialize controls which entries.
|
||||||
const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
|
const mapAwareStorage: PersistStorage<Pick<TransferStoreState, 'transfers'>> = {
|
||||||
@@ -173,6 +184,176 @@ export const useTransferStore = create<TransferStore>()(
|
|||||||
return { transfers: next };
|
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),
|
get: (id) => get().transfers.get(id),
|
||||||
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
|
listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray),
|
||||||
listForChannel: (channelId) =>
|
listForChannel: (channelId) =>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user