feat: add chunked desktop client release uploads
This commit is contained in:
@@ -75,7 +75,27 @@ export interface DesktopClientReleaseDownloadURLResult {
|
||||
expires_in?: number | null
|
||||
}
|
||||
|
||||
const DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS = 15 * 60 * 1000
|
||||
const DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
|
||||
const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024
|
||||
const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
|
||||
|
||||
export interface DesktopClientReleaseUploadProgress {
|
||||
loaded: number
|
||||
total?: number
|
||||
percent?: number
|
||||
stage?: 'uploading' | 'complete'
|
||||
chunkIndex?: number
|
||||
chunkCount?: number
|
||||
}
|
||||
|
||||
interface DesktopClientReleaseUploadSession {
|
||||
upload_id: string
|
||||
chunk_size_bytes: number
|
||||
chunk_count: number
|
||||
uploaded_chunks: number[]
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export const platformOptions = [
|
||||
{ label: 'macOS', value: 'darwin' },
|
||||
@@ -127,6 +147,20 @@ export const desktopClientReleaseApi = {
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
) {
|
||||
return uploadOSSInChunks(file, target)
|
||||
},
|
||||
async uploadOSSLegacy(
|
||||
file: File,
|
||||
target: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
) {
|
||||
const form = new FormData()
|
||||
@@ -142,6 +176,15 @@ export const desktopClientReleaseApi = {
|
||||
data: DesktopClientReleaseUploadResult
|
||||
}>('/desktop-client/releases/upload', form, {
|
||||
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
|
||||
onUploadProgress(event) {
|
||||
const total = event.total
|
||||
target.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total,
|
||||
percent: total ? Math.min(100, Math.round((event.loaded / total) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
})
|
||||
},
|
||||
})
|
||||
return response.data.data
|
||||
},
|
||||
@@ -155,6 +198,145 @@ export const desktopClientReleaseApi = {
|
||||
},
|
||||
}
|
||||
|
||||
async function uploadOSSInChunks(
|
||||
file: File,
|
||||
target: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
): Promise<DesktopClientReleaseUploadResult> {
|
||||
const session = await http.post<DesktopClientReleaseUploadSession>('/desktop-client/release-uploads', {
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
channel: target.channel,
|
||||
version: target.version,
|
||||
kind: target.kind ?? 'installer',
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.size,
|
||||
chunk_size_bytes: DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES,
|
||||
})
|
||||
|
||||
const chunkSize = session.chunk_size_bytes || DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES
|
||||
const chunkCount = session.chunk_count || Math.ceil(file.size / chunkSize)
|
||||
const uploaded = new Set(session.uploaded_chunks ?? [])
|
||||
let confirmedBytes = 0
|
||||
for (const index of uploaded) {
|
||||
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
|
||||
}
|
||||
emitChunkProgress(target, confirmedBytes, file.size, chunkCount)
|
||||
|
||||
try {
|
||||
for (let index = 0; index < chunkCount; index += 1) {
|
||||
if (uploaded.has(index)) continue
|
||||
await uploadChunkWithRetry(file, session.upload_id, index, chunkSize, chunkCount, confirmedBytes, target)
|
||||
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
|
||||
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
|
||||
}
|
||||
|
||||
target.onProgress?.({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
percent: 100,
|
||||
stage: 'complete',
|
||||
chunkCount,
|
||||
})
|
||||
return await http.post<DesktopClientReleaseUploadResult>(
|
||||
`/desktop-client/release-uploads/${session.upload_id}/complete`,
|
||||
)
|
||||
} catch (error) {
|
||||
void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadChunkWithRetry(
|
||||
file: File,
|
||||
uploadID: string,
|
||||
index: number,
|
||||
chunkSize: number,
|
||||
chunkCount: number,
|
||||
confirmedBytes: number,
|
||||
target: {
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
) {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt <= DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES; attempt += 1) {
|
||||
try {
|
||||
await uploadChunk(file, uploadID, index, chunkSize, chunkCount, confirmedBytes, target)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt === DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES) break
|
||||
await delay(400 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function uploadChunk(
|
||||
file: File,
|
||||
uploadID: string,
|
||||
index: number,
|
||||
chunkSize: number,
|
||||
chunkCount: number,
|
||||
confirmedBytes: number,
|
||||
target: {
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
) {
|
||||
const start = index * chunkSize
|
||||
const end = Math.min(start + chunkSize, file.size)
|
||||
const chunk = file.slice(start, end)
|
||||
const form = new FormData()
|
||||
form.append('file', chunk, file.name)
|
||||
await http.raw.post(`/desktop-client/release-uploads/${uploadID}/chunks/${index}`, form, {
|
||||
timeout: DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS,
|
||||
onUploadProgress(event) {
|
||||
const loaded = confirmedBytes + Math.min(event.loaded, chunk.size)
|
||||
target.onProgress?.({
|
||||
loaded,
|
||||
total: file.size,
|
||||
percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
chunkIndex: index,
|
||||
chunkCount,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function emitChunkProgress(
|
||||
target: { onProgress?: (progress: DesktopClientReleaseUploadProgress) => void },
|
||||
loaded: number,
|
||||
total: number,
|
||||
chunkCount: number,
|
||||
chunkIndex?: number,
|
||||
) {
|
||||
target.onProgress?.({
|
||||
loaded,
|
||||
total,
|
||||
percent: total ? Math.min(99, Math.round((loaded / total) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
chunkIndex,
|
||||
chunkCount,
|
||||
})
|
||||
}
|
||||
|
||||
function chunkByteSize(fileSize: number, chunkSize: number, index: number, chunkCount: number) {
|
||||
if (index < 0 || index >= chunkCount) return 0
|
||||
const start = index * chunkSize
|
||||
return Math.max(0, Math.min(chunkSize, fileSize - start))
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function platformLabel(value: string): string {
|
||||
return platformOptions.find((item) => item.value === value)?.label ?? value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user