MultipartUploader.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. const MB = 1024 * 1024
  2. const defaultOptions = {
  3. limit: 1,
  4. getChunkSize (file) {
  5. return Math.ceil(file.size / 10000)
  6. },
  7. onStart () {},
  8. onProgress () {},
  9. onPartComplete () {},
  10. onSuccess () {},
  11. onError (err) {
  12. throw err
  13. }
  14. }
  15. function remove (arr, el) {
  16. const i = arr.indexOf(el)
  17. if (i !== -1) arr.splice(i, 1)
  18. }
  19. class MultipartUploader {
  20. constructor (file, options) {
  21. this.options = {
  22. ...defaultOptions,
  23. ...options
  24. }
  25. // Use default `getChunkSize` if it was null or something
  26. if (!this.options.getChunkSize) {
  27. this.options.getChunkSize = defaultOptions.getChunkSize
  28. }
  29. this.file = file
  30. this.key = this.options.key || null
  31. this.uploadId = this.options.uploadId || null
  32. this.parts = []
  33. // Do `this.createdPromise.then(OP)` to execute an operation `OP` _only_ if the
  34. // upload was created already. That also ensures that the sequencing is right
  35. // (so the `OP` definitely happens if the upload is created).
  36. //
  37. // This mostly exists to make `_abortUpload` work well: only sending the abort request if
  38. // the upload was already created, and if the createMultipartUpload request is still in flight,
  39. // aborting it immediately after it finishes.
  40. this.createdPromise = Promise.reject() // eslint-disable-line prefer-promise-reject-errors
  41. this.isPaused = false
  42. this.chunks = null
  43. this.chunkState = null
  44. this.uploading = []
  45. this._initChunks()
  46. this.createdPromise.catch(() => {}) // silence uncaught rejection warning
  47. }
  48. _initChunks () {
  49. const chunks = []
  50. const desiredChunkSize = this.options.getChunkSize(this.file)
  51. // at least 5MB per request, at most 10k requests
  52. const minChunkSize = Math.max(5 * MB, Math.ceil(this.file.size / 10000))
  53. const chunkSize = Math.max(desiredChunkSize, minChunkSize)
  54. for (let i = 0; i < this.file.size; i += chunkSize) {
  55. const end = Math.min(this.file.size, i + chunkSize)
  56. chunks.push(this.file.slice(i, end))
  57. }
  58. this.chunks = chunks
  59. this.chunkState = chunks.map(() => ({
  60. uploaded: 0,
  61. busy: false,
  62. done: false
  63. }))
  64. }
  65. _createUpload () {
  66. this.createdPromise = Promise.resolve().then(() =>
  67. this.options.createMultipartUpload()
  68. )
  69. return this.createdPromise.then((result) => {
  70. const valid = typeof result === 'object' && result &&
  71. typeof result.uploadId === 'string' &&
  72. typeof result.key === 'string'
  73. if (!valid) {
  74. throw new TypeError('AwsS3/Multipart: Got incorrect result from `createMultipartUpload()`, expected an object `{ uploadId, key }`.')
  75. }
  76. this.key = result.key
  77. this.uploadId = result.uploadId
  78. this.options.onStart(result)
  79. this._uploadParts()
  80. }).catch((err) => {
  81. this._onError(err)
  82. })
  83. }
  84. _resumeUpload () {
  85. return Promise.resolve().then(() =>
  86. this.options.listParts({
  87. uploadId: this.uploadId,
  88. key: this.key
  89. })
  90. ).then((parts) => {
  91. parts.forEach((part) => {
  92. const i = part.PartNumber - 1
  93. this.chunkState[i] = {
  94. uploaded: part.Size,
  95. etag: part.ETag,
  96. done: true
  97. }
  98. // Only add if we did not yet know about this part.
  99. if (!this.parts.some((p) => p.PartNumber === part.PartNumber)) {
  100. this.parts.push({
  101. PartNumber: part.PartNumber,
  102. ETag: part.ETag
  103. })
  104. }
  105. })
  106. this._uploadParts()
  107. }).catch((err) => {
  108. this._onError(err)
  109. })
  110. }
  111. _uploadParts () {
  112. if (this.isPaused) return
  113. const need = this.options.limit - this.uploading.length
  114. if (need === 0) return
  115. // All parts are uploaded.
  116. if (this.chunkState.every((state) => state.done)) {
  117. this._completeUpload()
  118. return
  119. }
  120. const candidates = []
  121. for (let i = 0; i < this.chunkState.length; i++) {
  122. const state = this.chunkState[i]
  123. if (state.done || state.busy) continue
  124. candidates.push(i)
  125. if (candidates.length >= need) {
  126. break
  127. }
  128. }
  129. candidates.forEach((index) => {
  130. this._uploadPart(index)
  131. })
  132. }
  133. _uploadPart (index) {
  134. const body = this.chunks[index]
  135. this.chunkState[index].busy = true
  136. return Promise.resolve().then(() =>
  137. this.options.prepareUploadPart({
  138. key: this.key,
  139. uploadId: this.uploadId,
  140. body,
  141. number: index + 1
  142. })
  143. ).then((result) => {
  144. const valid = typeof result === 'object' && result &&
  145. typeof result.url === 'string'
  146. if (!valid) {
  147. throw new TypeError('AwsS3/Multipart: Got incorrect result from `prepareUploadPart()`, expected an object `{ url }`.')
  148. }
  149. return result
  150. }).then(({ url, headers }) => {
  151. this._uploadPartBytes(index, url, headers)
  152. }, (err) => {
  153. this._onError(err)
  154. })
  155. }
  156. _onPartProgress (index, sent, total) {
  157. this.chunkState[index].uploaded = sent
  158. const totalUploaded = this.chunkState.reduce((n, c) => n + c.uploaded, 0)
  159. this.options.onProgress(totalUploaded, this.file.size)
  160. }
  161. _onPartComplete (index, etag) {
  162. this.chunkState[index].etag = etag
  163. this.chunkState[index].done = true
  164. const part = {
  165. PartNumber: index + 1,
  166. ETag: etag
  167. }
  168. this.parts.push(part)
  169. this.options.onPartComplete(part)
  170. this._uploadParts()
  171. }
  172. _uploadPartBytes (index, url, headers) {
  173. const body = this.chunks[index]
  174. const xhr = new XMLHttpRequest()
  175. xhr.open('PUT', url, true)
  176. if (headers) {
  177. Object.keys(headers).map((key) => {
  178. xhr.setRequestHeader(key, headers[key])
  179. })
  180. }
  181. xhr.responseType = 'text'
  182. this.uploading.push(xhr)
  183. xhr.upload.addEventListener('progress', (ev) => {
  184. if (!ev.lengthComputable) return
  185. this._onPartProgress(index, ev.loaded, ev.total)
  186. })
  187. xhr.addEventListener('abort', (ev) => {
  188. remove(this.uploading, ev.target)
  189. this.chunkState[index].busy = false
  190. })
  191. xhr.addEventListener('load', (ev) => {
  192. remove(this.uploading, ev.target)
  193. this.chunkState[index].busy = false
  194. if (ev.target.status < 200 || ev.target.status >= 300) {
  195. this._onError(new Error('Non 2xx'))
  196. return
  197. }
  198. this._onPartProgress(index, body.size, body.size)
  199. // NOTE This must be allowed by CORS.
  200. const etag = ev.target.getResponseHeader('ETag')
  201. if (etag === null) {
  202. this._onError(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. Seee https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
  203. return
  204. }
  205. this._onPartComplete(index, etag)
  206. })
  207. xhr.addEventListener('error', (ev) => {
  208. remove(this.uploading, ev.target)
  209. this.chunkState[index].busy = false
  210. const error = new Error('Unknown error')
  211. error.source = ev.target
  212. this._onError(error)
  213. })
  214. xhr.send(body)
  215. }
  216. _completeUpload () {
  217. // Parts may not have completed uploading in sorted order, if limit > 1.
  218. this.parts.sort((a, b) => a.PartNumber - b.PartNumber)
  219. return Promise.resolve().then(() =>
  220. this.options.completeMultipartUpload({
  221. key: this.key,
  222. uploadId: this.uploadId,
  223. parts: this.parts
  224. })
  225. ).then((result) => {
  226. this.options.onSuccess(result)
  227. }, (err) => {
  228. this._onError(err)
  229. })
  230. }
  231. _abortUpload () {
  232. this.uploading.slice().forEach(xhr => {
  233. xhr.abort()
  234. })
  235. this.createdPromise.then(() => {
  236. this.options.abortMultipartUpload({
  237. key: this.key,
  238. uploadId: this.uploadId
  239. })
  240. }, () => {
  241. // if the creation failed we do not need to abort
  242. })
  243. this.uploading = []
  244. }
  245. _onError (err) {
  246. this.options.onError(err)
  247. }
  248. start () {
  249. this.isPaused = false
  250. if (this.uploadId) {
  251. this._resumeUpload()
  252. } else {
  253. this._createUpload()
  254. }
  255. }
  256. pause () {
  257. const inProgress = this.uploading.slice()
  258. inProgress.forEach((xhr) => {
  259. xhr.abort()
  260. })
  261. this.isPaused = true
  262. }
  263. abort (opts = {}) {
  264. const really = opts.really || false
  265. if (!really) return this.pause()
  266. this._abortUpload()
  267. }
  268. }
  269. module.exports = MultipartUploader