File size: 2,840 Bytes
cd99321
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import type { AxiosProgressEvent } from 'axios'

export interface UploadProgress {
  done: number
  total: number
  failed: number
  percent: number
}

export interface ResilientResult<T> {
  succeeded: T[]
  failed: File[]
}

export interface UploadOpts {
  onUploadProgress: (e: AxiosProgressEvent) => void
  idempotencyKey: string
}

const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms))

function isRetryable(err: unknown): boolean {
  if (err && typeof err === 'object' && 'response' in err) {
    const status = (err as { response?: { status?: number } }).response?.status
    if (status !== undefined && status >= 400 && status < 500) return false
  }
  return true
}

export async function uploadFilesResilient<T>(
  files: File[],
  uploadOne: (file: File, opts: UploadOpts) => Promise<T[]>,
  cbs?: {
    concurrency?: number
    retries?: number
    onProgress?: (p: UploadProgress) => void
    onUploaded?: (items: T[]) => void
  },
): Promise<ResilientResult<T>> {
  const concurrency = cbs?.concurrency ?? 3
  const maxRetries = cbs?.retries ?? 2

  const totalBytes = files.reduce((s, f) => s + f.size, 0)
  const loadedMap = new Map<number, number>()
  let doneCount = 0
  let failedCount = 0

  const emitProgress = () => {
    if (!cbs?.onProgress) return
    const sumLoaded = Array.from(loadedMap.values()).reduce((a, b) => a + b, 0)
    const percent = totalBytes > 0 ? Math.round((sumLoaded / totalBytes) * 100) : 0
    cbs.onProgress({ done: doneCount, total: files.length, failed: failedCount, percent })
  }

  const succeeded: T[] = []
  const failedFiles: File[] = []

  let idx = 0

  async function worker() {
    while (true) {
      const i = idx++
      if (i >= files.length) break
      const file = files[i]
      const idempotencyKey = crypto.randomUUID()
      loadedMap.set(i, 0)

      let items: T[] | null = null
      for (let attempt = 0; attempt <= maxRetries; attempt++) {
        if (attempt > 0) await sleep(400 * attempt)
        try {
          items = await uploadOne(file, {
            idempotencyKey,
            onUploadProgress: (e) => {
              loadedMap.set(i, e.loaded)
              emitProgress()
            },
          })
          break
        } catch (err) {
          if (!isRetryable(err) || attempt === maxRetries) {
            items = null
            break
          }
        }
      }

      if (items !== null) {
        succeeded.push(...items)
        cbs?.onUploaded?.(items)
        loadedMap.set(i, file.size)
        doneCount++
      } else {
        failedFiles.push(file)
        loadedMap.set(i, 0)
        failedCount++
      }
      emitProgress()
    }
  }

  const workers = Array.from({ length: Math.min(concurrency, files.length) }, () => worker())
  await Promise.all(workers)

  return { succeeded, failed: failedFiles }
}