/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {Endpoints} from '@app/Endpoints'; import http from '@app/lib/HttpClient'; import {Logger} from '@app/lib/Logger'; import {CHUNKED_UPLOAD_CHUNK_SIZE} from '@fluxer/constants/src/LimitConstants'; const logger = new Logger('ChunkedUploadService'); const MAX_CONCURRENT_CHUNKS = 4; const MAX_CHUNK_RETRIES = 3; const RETRY_BASE_DELAY_MS = 1000; interface ChunkedUploadResult { upload_filename: string; file_size: number; content_type: string; } interface InitiateUploadResponse { upload_id: string; upload_filename: string; chunk_size: number; chunk_count: number; } interface UploadChunkResponse { etag: string; } interface CompleteUploadResponse { upload_filename: string; file_size: number; content_type: string; } export async function uploadFileChunked( channelId: string, file: File, onProgress?: (loaded: number, total: number) => void, signal?: AbortSignal, ): Promise { const initiateResponse = await http.post({ url: Endpoints.CHANNEL_CHUNKED_UPLOADS(channelId), body: { filename: file.name, file_size: file.size, }, signal, rejectWithError: true, }); const {upload_id, chunk_size, chunk_count} = initiateResponse.body; logger.debug(`Initiated chunked upload: ${upload_id}, ${chunk_count} chunks of ${chunk_size} bytes`); const chunkProgress = new Array(chunk_count).fill(0); const etags = new Array<{chunk_index: number; etag: string}>(chunk_count); function reportProgress() { if (!onProgress) return; const loaded = chunkProgress.reduce((sum, bytes) => sum + bytes, 0); onProgress(loaded, file.size); } const chunkIndices = Array.from({length: chunk_count}, (_, i) => i); let cursor = 0; const activeTasks: Array> = []; async function uploadOneChunk(chunkIndex: number): Promise { const start = chunkIndex * chunk_size; const end = Math.min(start + chunk_size, file.size); const chunkBlob = file.slice(start, end); const chunkData = new Uint8Array(await chunkBlob.arrayBuffer()); const chunkLength = chunkData.byteLength; let lastError: unknown; for (let attempt = 0; attempt <= MAX_CHUNK_RETRIES; attempt++) { if (signal?.aborted) { throw new DOMException('Upload cancelled', 'AbortError'); } try { const response = await http.put({ url: Endpoints.CHANNEL_CHUNKED_UPLOAD_CHUNK(channelId, upload_id, chunkIndex), body: chunkData, headers: {'Content-Type': 'application/octet-stream'}, signal, rejectWithError: true, }); etags[chunkIndex] = {chunk_index: chunkIndex, etag: response.body.etag}; chunkProgress[chunkIndex] = chunkLength; reportProgress(); return; } catch (error) { lastError = error; if (signal?.aborted) { throw error; } const isRetryable = error instanceof Error && 'status' in error && ((error as {status: number}).status >= 500 || (error as {status: number}).status === 429); if (!isRetryable || attempt === MAX_CHUNK_RETRIES) { throw error; } const delay = RETRY_BASE_DELAY_MS * 2 ** attempt; logger.debug( `Chunk ${chunkIndex} failed (attempt ${attempt + 1}/${MAX_CHUNK_RETRIES + 1}), retrying in ${delay}ms`, ); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; } await new Promise((resolve, reject) => { let settled = false; function settle(error?: unknown) { if (settled) return; settled = true; if (error) { reject(error); } else { resolve(); } } function scheduleNext() { while (activeTasks.length < MAX_CONCURRENT_CHUNKS && cursor < chunkIndices.length) { const chunkIndex = chunkIndices[cursor++]; const task = uploadOneChunk(chunkIndex).then( () => { const idx = activeTasks.indexOf(task); if (idx !== -1) activeTasks.splice(idx, 1); if (cursor >= chunkIndices.length && activeTasks.length === 0) { settle(); } else { scheduleNext(); } }, (error) => { settle(error); }, ); activeTasks.push(task); } } scheduleNext(); }); logger.debug(`All ${chunk_count} chunks uploaded, completing upload`); const completeResponse = await http.post({ url: Endpoints.CHANNEL_CHUNKED_UPLOAD_COMPLETE(channelId, upload_id), body: {etags}, signal, rejectWithError: true, }); return { upload_filename: completeResponse.body.upload_filename, file_size: completeResponse.body.file_size, content_type: completeResponse.body.content_type, }; } export function shouldUseChunkedUpload(file: File): boolean { return file.size > CHUNKED_UPLOAD_CHUNK_SIZE; }