MultipartUploader.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import type { Uppy } from '@uppy/core'
  2. import { AbortController } from '@uppy/utils/lib/AbortController'
  3. import type { Meta, Body, UppyFile } from '@uppy/utils/lib/UppyFile'
  4. import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue.ts'
  5. const MB = 1024 * 1024
  6. interface MultipartUploaderOptions<M extends Meta, B extends Body> {
  7. getChunkSize?: (file: { size: number }) => number
  8. onProgress?: (bytesUploaded: number, bytesTotal: number) => void
  9. onPartComplete?: (part: { PartNumber: number; ETag: string }) => void
  10. shouldUseMultipart?: boolean | ((file: UppyFile<M, B>) => boolean)
  11. onSuccess?: (result: B) => void
  12. onError?: (err: unknown) => void
  13. companionComm: HTTPCommunicationQueue<M, B>
  14. file: UppyFile<M, B>
  15. log: Uppy<M, B>['log']
  16. uploadId: string
  17. key: string
  18. }
  19. const defaultOptions = {
  20. getChunkSize(file: { size: number }) {
  21. return Math.ceil(file.size / 10000)
  22. },
  23. onProgress() {},
  24. onPartComplete() {},
  25. onSuccess() {},
  26. onError(err: unknown) {
  27. throw err
  28. },
  29. } satisfies Partial<MultipartUploaderOptions<any, any>>
  30. export interface Chunk {
  31. getData: () => Blob
  32. onProgress: (ev: ProgressEvent) => void
  33. onComplete: (etag: string) => void
  34. shouldUseMultipart: boolean
  35. setAsUploaded?: () => void
  36. }
  37. function ensureInt<T>(value: T): T extends number | string ? number : never {
  38. if (typeof value === 'string') {
  39. // @ts-expect-error TS is not able to recognize it's fine.
  40. return parseInt(value, 10)
  41. }
  42. if (typeof value === 'number') {
  43. // @ts-expect-error TS is not able to recognize it's fine.
  44. return value
  45. }
  46. throw new TypeError('Expected a number')
  47. }
  48. export const pausingUploadReason = Symbol('pausing upload, not an actual error')
  49. /**
  50. * A MultipartUploader instance is used per file upload to determine whether a
  51. * upload should be done as multipart or as a regular S3 upload
  52. * (based on the user-provided `shouldUseMultipart` option value) and to manage
  53. * the chunk splitting.
  54. */
  55. class MultipartUploader<M extends Meta, B extends Body> {
  56. options: MultipartUploaderOptions<M, B> &
  57. Required<Pick<MultipartUploaderOptions<M, B>, keyof typeof defaultOptions>>
  58. #abortController = new AbortController()
  59. #chunks: Array<Chunk | null> = []
  60. #chunkState: { uploaded: number; etag?: string; done?: boolean }[] = []
  61. /**
  62. * The (un-chunked) data to upload.
  63. */
  64. #data: Blob
  65. #file: UppyFile<M, B>
  66. #uploadHasStarted = false
  67. #onError: (err: unknown) => void
  68. #onSuccess: (result: B) => void
  69. #shouldUseMultipart: MultipartUploaderOptions<M, B>['shouldUseMultipart']
  70. #isRestoring: boolean
  71. #onReject = (err: unknown) =>
  72. (err as any)?.cause === pausingUploadReason ? null : this.#onError(err)
  73. #maxMultipartParts = 10_000
  74. #minPartSize = 5 * MB
  75. constructor(data: Blob, options: MultipartUploaderOptions<M, B>) {
  76. this.options = {
  77. ...defaultOptions,
  78. ...options,
  79. }
  80. // Use default `getChunkSize` if it was null or something
  81. this.options.getChunkSize ??= defaultOptions.getChunkSize
  82. this.#data = data
  83. this.#file = options.file
  84. this.#onSuccess = this.options.onSuccess
  85. this.#onError = this.options.onError
  86. this.#shouldUseMultipart = this.options.shouldUseMultipart
  87. // When we are restoring an upload, we already have an UploadId and a Key. Otherwise
  88. // we need to call `createMultipartUpload` to get an `uploadId` and a `key`.
  89. // Non-multipart uploads are not restorable.
  90. this.#isRestoring = (options.uploadId && options.key) as any as boolean
  91. this.#initChunks()
  92. }
  93. // initChunks checks the user preference for using multipart uploads (opts.shouldUseMultipart)
  94. // and calculates the optimal part size. When using multipart part uploads every part except for the last has
  95. // to be at least 5 MB and there can be no more than 10K parts.
  96. // This means we sometimes need to change the preferred part size from the user in order to meet these requirements.
  97. #initChunks() {
  98. const fileSize = this.#data.size
  99. const shouldUseMultipart =
  100. typeof this.#shouldUseMultipart === 'function' ?
  101. this.#shouldUseMultipart(this.#file)
  102. : Boolean(this.#shouldUseMultipart)
  103. if (shouldUseMultipart && fileSize > this.#minPartSize) {
  104. // At least 5MB per request:
  105. let chunkSize = Math.max(
  106. this.options.getChunkSize(this.#data) as number, // Math.max can take undefined but TS does not think so
  107. this.#minPartSize,
  108. )
  109. let arraySize = Math.floor(fileSize / chunkSize)
  110. // At most 10k requests per file:
  111. if (arraySize > this.#maxMultipartParts) {
  112. arraySize = this.#maxMultipartParts
  113. chunkSize = fileSize / this.#maxMultipartParts
  114. }
  115. this.#chunks = Array(arraySize)
  116. for (let offset = 0, j = 0; offset < fileSize; offset += chunkSize, j++) {
  117. const end = Math.min(fileSize, offset + chunkSize)
  118. // Defer data fetching/slicing until we actually need the data, because it's slow if we have a lot of files
  119. const getData = () => {
  120. const i2 = offset
  121. return this.#data.slice(i2, end)
  122. }
  123. this.#chunks[j] = {
  124. getData,
  125. onProgress: this.#onPartProgress(j),
  126. onComplete: this.#onPartComplete(j),
  127. shouldUseMultipart,
  128. }
  129. if (this.#isRestoring) {
  130. const size =
  131. offset + chunkSize > fileSize ? fileSize - offset : chunkSize
  132. // setAsUploaded is called by listPart, to keep up-to-date the
  133. // quantity of data that is left to actually upload.
  134. this.#chunks[j]!.setAsUploaded = () => {
  135. this.#chunks[j] = null
  136. this.#chunkState[j].uploaded = size
  137. }
  138. }
  139. }
  140. } else {
  141. this.#chunks = [
  142. {
  143. getData: () => this.#data,
  144. onProgress: this.#onPartProgress(0),
  145. onComplete: this.#onPartComplete(0),
  146. shouldUseMultipart,
  147. },
  148. ]
  149. }
  150. this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 }))
  151. }
  152. #createUpload() {
  153. this.options.companionComm
  154. .uploadFile(
  155. this.#file,
  156. this.#chunks as Chunk[],
  157. this.#abortController.signal,
  158. )
  159. .then(this.#onSuccess, this.#onReject)
  160. this.#uploadHasStarted = true
  161. }
  162. #resumeUpload() {
  163. this.options.companionComm
  164. .resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
  165. .then(this.#onSuccess, this.#onReject)
  166. }
  167. #onPartProgress = (index: number) => (ev: ProgressEvent) => {
  168. if (!ev.lengthComputable) return
  169. this.#chunkState[index].uploaded = ensureInt(ev.loaded)
  170. const totalUploaded = this.#chunkState.reduce((n, c) => n + c.uploaded, 0)
  171. this.options.onProgress(totalUploaded, this.#data.size)
  172. }
  173. #onPartComplete = (index: number) => (etag: string) => {
  174. // This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers.
  175. this.#chunks[index] = null
  176. this.#chunkState[index].etag = etag
  177. this.#chunkState[index].done = true
  178. const part = {
  179. PartNumber: index + 1,
  180. ETag: etag,
  181. }
  182. this.options.onPartComplete(part)
  183. }
  184. #abortUpload() {
  185. this.#abortController.abort()
  186. this.options.companionComm
  187. .abortFileUpload(this.#file)
  188. .catch((err: unknown) => this.options.log(err as Error))
  189. }
  190. start(): void {
  191. if (this.#uploadHasStarted) {
  192. if (!this.#abortController.signal.aborted)
  193. this.#abortController.abort(pausingUploadReason)
  194. this.#abortController = new AbortController()
  195. this.#resumeUpload()
  196. } else if (this.#isRestoring) {
  197. this.options.companionComm.restoreUploadFile(this.#file, {
  198. uploadId: this.options.uploadId,
  199. key: this.options.key,
  200. })
  201. this.#resumeUpload()
  202. } else {
  203. this.#createUpload()
  204. }
  205. }
  206. pause(): void {
  207. this.#abortController.abort(pausingUploadReason)
  208. // Swap it out for a new controller, because this instance may be resumed later.
  209. this.#abortController = new AbortController()
  210. }
  211. abort(opts?: { really?: boolean }): void {
  212. if (opts?.really) this.#abortUpload()
  213. else this.pause()
  214. }
  215. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
  216. private [Symbol.for('uppy test: getChunkState')]() {
  217. return this.#chunkState
  218. }
  219. }
  220. export default MultipartUploader