MiniXHRUpload.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import { nanoid } from 'nanoid/non-secure'
  2. import { Socket } from '@uppy/companion-client'
  3. import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress'
  4. import getSocketHost from '@uppy/utils/lib/getSocketHost'
  5. import EventManager from '@uppy/utils/lib/EventManager'
  6. import ProgressTimeout from '@uppy/utils/lib/ProgressTimeout'
  7. import ErrorWithCause from '@uppy/utils/lib/ErrorWithCause'
  8. import NetworkError from '@uppy/utils/lib/NetworkError'
  9. import isNetworkError from '@uppy/utils/lib/isNetworkError'
  10. import { internalRateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  11. // See XHRUpload
  12. function buildResponseError (xhr, error) {
  13. if (isNetworkError(xhr)) return new NetworkError(error, xhr)
  14. const err = new ErrorWithCause('Upload error', { cause: error })
  15. err.request = xhr
  16. return err
  17. }
  18. // See XHRUpload
  19. function setTypeInBlob (file) {
  20. const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type)
  21. return dataWithUpdatedType
  22. }
  23. function addMetadata (formData, meta, opts) {
  24. const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
  25. ? opts.allowedMetaFields
  26. // Send along all fields by default.
  27. : Object.keys(meta)
  28. allowedMetaFields.forEach((item) => {
  29. formData.append(item, meta[item])
  30. })
  31. }
  32. function createFormDataUpload (file, opts) {
  33. const formPost = new FormData()
  34. addMetadata(formPost, file.meta, opts)
  35. const dataWithUpdatedType = setTypeInBlob(file)
  36. if (file.name) {
  37. formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name)
  38. } else {
  39. formPost.append(opts.fieldName, dataWithUpdatedType)
  40. }
  41. return formPost
  42. }
  43. const createBareUpload = file => file.data
  44. export default class MiniXHRUpload {
  45. constructor (uppy, opts) {
  46. this.uppy = uppy
  47. this.opts = {
  48. validateStatus (status) {
  49. return status >= 200 && status < 300
  50. },
  51. ...opts,
  52. }
  53. this.requests = opts[internalRateLimitedQueue]
  54. this.uploaderEvents = Object.create(null)
  55. this.i18n = opts.i18n
  56. }
  57. getOptions (file) {
  58. const { uppy } = this
  59. const overrides = uppy.getState().xhrUpload
  60. const opts = {
  61. ...this.opts,
  62. ...(overrides || {}),
  63. ...(file.xhrUpload || {}),
  64. headers: {
  65. ...this.opts.headers,
  66. ...overrides?.headers,
  67. ...file.xhrUpload?.headers,
  68. },
  69. }
  70. return opts
  71. }
  72. #addEventHandlerForFile (eventName, fileID, eventHandler) {
  73. this.uploaderEvents[fileID].on(eventName, (fileOrID) => {
  74. // TODO (major): refactor Uppy events to consistently send file objects (or consistently IDs)
  75. // We created a generic `addEventListenerForFile` but not all events
  76. // use file IDs, some use files, so we need to do this weird check.
  77. const id = fileOrID?.id ?? fileOrID
  78. if (fileID === id) eventHandler()
  79. })
  80. }
  81. #addEventHandlerIfFileStillExists (eventName, fileID, eventHandler) {
  82. this.uploaderEvents[fileID].on(eventName, (...args) => {
  83. if (this.uppy.getFile(fileID)) eventHandler(...args)
  84. })
  85. }
  86. uploadLocalFile (file) {
  87. const opts = this.getOptions(file)
  88. return new Promise((resolve, reject) => {
  89. // This is done in index.js in the S3 plugin.
  90. // this.uppy.emit('upload-started', file)
  91. const data = opts.formData
  92. ? createFormDataUpload(file, opts)
  93. : createBareUpload(file, opts)
  94. const xhr = new XMLHttpRequest()
  95. this.uploaderEvents[file.id] = new EventManager(this.uppy)
  96. const timer = new ProgressTimeout(opts.timeout, () => {
  97. xhr.abort()
  98. // eslint-disable-next-line no-use-before-define
  99. queuedRequest.done()
  100. const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(opts.timeout / 1000) }))
  101. this.uppy.emit('upload-error', file, error)
  102. reject(error)
  103. })
  104. const id = nanoid()
  105. xhr.upload.addEventListener('loadstart', () => {
  106. this.uppy.log(`[AwsS3/XHRUpload] ${id} started`)
  107. })
  108. xhr.upload.addEventListener('progress', (ev) => {
  109. this.uppy.log(`[AwsS3/XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  110. // Begin checking for timeouts when progress starts, instead of loading,
  111. // to avoid timing out requests on browser concurrency queue
  112. timer.progress()
  113. if (ev.lengthComputable) {
  114. this.uppy.emit('upload-progress', file, {
  115. uploader: this,
  116. bytesUploaded: ev.loaded,
  117. bytesTotal: ev.total,
  118. })
  119. }
  120. })
  121. xhr.addEventListener('load', (ev) => {
  122. this.uppy.log(`[AwsS3/XHRUpload] ${id} finished`)
  123. timer.done()
  124. // eslint-disable-next-line no-use-before-define
  125. queuedRequest.done()
  126. if (this.uploaderEvents[file.id]) {
  127. this.uploaderEvents[file.id].remove()
  128. this.uploaderEvents[file.id] = null
  129. }
  130. if (opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  131. const body = opts.getResponseData(xhr.responseText, xhr)
  132. const uploadURL = body[opts.responseUrlFieldName]
  133. const uploadResp = {
  134. status: ev.target.status,
  135. body,
  136. uploadURL,
  137. }
  138. this.uppy.emit('upload-success', file, uploadResp)
  139. if (uploadURL) {
  140. this.uppy.log(`Download ${file.name} from ${uploadURL}`)
  141. }
  142. return resolve(file)
  143. }
  144. const body = opts.getResponseData(xhr.responseText, xhr)
  145. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  146. const response = {
  147. status: ev.target.status,
  148. body,
  149. }
  150. this.uppy.emit('upload-error', file, error, response)
  151. return reject(error)
  152. })
  153. xhr.addEventListener('error', () => {
  154. this.uppy.log(`[AwsS3/XHRUpload] ${id} errored`)
  155. timer.done()
  156. // eslint-disable-next-line no-use-before-define
  157. queuedRequest.done()
  158. if (this.uploaderEvents[file.id]) {
  159. this.uploaderEvents[file.id].remove()
  160. this.uploaderEvents[file.id] = null
  161. }
  162. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  163. this.uppy.emit('upload-error', file, error)
  164. return reject(error)
  165. })
  166. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  167. // IE10 does not allow setting `withCredentials` and `responseType`
  168. // before `open()` is called. It’s important to set withCredentials
  169. // to a boolean, otherwise React Native crashes
  170. xhr.withCredentials = Boolean(opts.withCredentials)
  171. if (opts.responseType !== '') {
  172. xhr.responseType = opts.responseType
  173. }
  174. Object.keys(opts.headers).forEach((header) => {
  175. xhr.setRequestHeader(header, opts.headers[header])
  176. })
  177. const queuedRequest = this.requests.run(() => {
  178. xhr.send(data)
  179. return () => {
  180. // eslint-disable-next-line no-use-before-define
  181. timer.done()
  182. xhr.abort()
  183. }
  184. }, { priority: 1 })
  185. this.#addEventHandlerForFile('file-removed', file.id, () => {
  186. queuedRequest.abort()
  187. reject(new Error('File removed'))
  188. })
  189. this.#addEventHandlerIfFileStillExists('cancel-all', file.id, ({ reason } = {}) => {
  190. if (reason === 'user') {
  191. queuedRequest.abort()
  192. }
  193. reject(new Error('Upload cancelled'))
  194. })
  195. })
  196. }
  197. async connectToServerSocket (file) {
  198. return new Promise((resolve, reject) => {
  199. const opts = this.getOptions(file)
  200. const token = file.serverToken
  201. const host = getSocketHost(file.remote.companionUrl)
  202. let socket
  203. const createSocket = () => {
  204. if (socket != null) return
  205. socket = new Socket({ target: `${host}/api/${token}` })
  206. socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
  207. socket.on('success', (data) => {
  208. const body = opts.getResponseData(data.response.responseText, data.response)
  209. const uploadURL = body[opts.responseUrlFieldName]
  210. const uploadResp = {
  211. status: data.response.status,
  212. body,
  213. uploadURL,
  214. bytesUploaded: data.bytesUploaded,
  215. }
  216. this.uppy.emit('upload-success', file, uploadResp)
  217. queuedRequest.done() // eslint-disable-line no-use-before-define
  218. socket.close()
  219. if (this.uploaderEvents[file.id]) {
  220. this.uploaderEvents[file.id].remove()
  221. this.uploaderEvents[file.id] = null
  222. }
  223. return resolve()
  224. })
  225. socket.on('error', (errData) => {
  226. const resp = errData.response
  227. const error = resp
  228. ? opts.getResponseError(resp.responseText, resp)
  229. : new ErrorWithCause(errData.error.message, { cause: errData.error })
  230. this.uppy.emit('upload-error', file, error)
  231. queuedRequest.done() // eslint-disable-line no-use-before-define
  232. if (this.uploaderEvents[file.id]) {
  233. this.uploaderEvents[file.id].remove()
  234. this.uploaderEvents[file.id] = null
  235. }
  236. reject(error)
  237. })
  238. }
  239. this.uploaderEvents[file.id] = new EventManager(this.uppy)
  240. let queuedRequest = this.requests.run(() => {
  241. if (file.isPaused) {
  242. socket?.send('pause', {})
  243. } else {
  244. createSocket()
  245. }
  246. return () => socket.close()
  247. })
  248. this.#addEventHandlerForFile('file-removed', file.id, () => {
  249. socket?.send('cancel', {})
  250. queuedRequest.abort()
  251. resolve(`upload ${file.id} was removed`)
  252. })
  253. this.#addEventHandlerIfFileStillExists('cancel-all', file.id, ({ reason } = {}) => {
  254. if (reason === 'user') {
  255. socket?.send('cancel', {})
  256. queuedRequest.abort()
  257. }
  258. resolve(`upload ${file.id} was canceled`)
  259. })
  260. const onRetryRequest = () => {
  261. if (socket == null) {
  262. queuedRequest.abort()
  263. } else {
  264. socket.send('pause', {})
  265. queuedRequest.done()
  266. }
  267. queuedRequest = this.requests.run(() => {
  268. if (!file.isPaused) {
  269. if (socket == null) {
  270. createSocket()
  271. } else {
  272. socket.send('resume', {})
  273. }
  274. }
  275. return () => socket.close()
  276. })
  277. }
  278. this.#addEventHandlerForFile('upload-retry', file.id, onRetryRequest)
  279. this.#addEventHandlerIfFileStillExists('retry-all', file.id, onRetryRequest)
  280. }).catch((err) => {
  281. this.uppy.emit('upload-error', file, err)
  282. return Promise.reject(err)
  283. })
  284. }
  285. }