MiniXHRUpload.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { nanoid } from 'nanoid/non-secure'
  2. import { Provider, RequestClient, Socket } from '@uppy/companion-client'
  3. import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress'
  4. import getSocketHost from '@uppy/utils/lib/getSocketHost'
  5. import EventTracker from '@uppy/utils/lib/EventTracker'
  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. #queueRequestSocketToken
  46. constructor (uppy, opts) {
  47. this.uppy = uppy
  48. this.opts = {
  49. validateStatus (status) {
  50. return status >= 200 && status < 300
  51. },
  52. ...opts,
  53. }
  54. this.requests = opts[internalRateLimitedQueue]
  55. this.uploaderEvents = Object.create(null)
  56. this.i18n = opts.i18n
  57. this.#queueRequestSocketToken = this.requests.wrapPromiseFunction(this.#requestSocketToken)
  58. }
  59. #getOptions (file) {
  60. const { uppy } = this
  61. const overrides = uppy.getState().xhrUpload
  62. const opts = {
  63. ...this.opts,
  64. ...(overrides || {}),
  65. ...(file.xhrUpload || {}),
  66. headers: {
  67. ...this.opts.headers,
  68. ...overrides?.headers,
  69. ...file.xhrUpload?.headers,
  70. },
  71. }
  72. return opts
  73. }
  74. uploadFile (id, current, total) {
  75. const file = this.uppy.getFile(id)
  76. if (file.error) {
  77. throw new Error(file.error)
  78. } else if (file.isRemote) {
  79. return this.#uploadRemoteFile(file, current, total)
  80. }
  81. return this.#uploadLocalFile(file, current, total)
  82. }
  83. #addEventHandlerForFile (eventName, fileID, eventHandler) {
  84. this.uploaderEvents[fileID].on(eventName, (fileOrID) => {
  85. // TODO (major): refactor Uppy events to consistently send file objects (or consistently IDs)
  86. // We created a generic `addEventListenerForFile` but not all events
  87. // use file IDs, some use files, so we need to do this weird check.
  88. const id = fileOrID?.id ?? fileOrID
  89. if (fileID === id) eventHandler()
  90. })
  91. }
  92. #addEventHandlerIfFileStillExists (eventName, fileID, eventHandler) {
  93. this.uploaderEvents[fileID].on(eventName, (...args) => {
  94. if (this.uppy.getFile(fileID)) eventHandler(...args)
  95. })
  96. }
  97. #uploadLocalFile (file, current, total) {
  98. const opts = this.#getOptions(file)
  99. this.uppy.log(`uploading ${current} of ${total}`)
  100. return new Promise((resolve, reject) => {
  101. // This is done in index.js in the S3 plugin.
  102. // this.uppy.emit('upload-started', file)
  103. const data = opts.formData
  104. ? createFormDataUpload(file, opts)
  105. : createBareUpload(file, opts)
  106. const xhr = new XMLHttpRequest()
  107. this.uploaderEvents[file.id] = new EventTracker(this.uppy)
  108. const timer = new ProgressTimeout(opts.timeout, () => {
  109. xhr.abort()
  110. // eslint-disable-next-line no-use-before-define
  111. queuedRequest.done()
  112. const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(opts.timeout / 1000) }))
  113. this.uppy.emit('upload-error', file, error)
  114. reject(error)
  115. })
  116. const id = nanoid()
  117. xhr.upload.addEventListener('loadstart', () => {
  118. this.uppy.log(`[AwsS3/XHRUpload] ${id} started`)
  119. })
  120. xhr.upload.addEventListener('progress', (ev) => {
  121. this.uppy.log(`[AwsS3/XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  122. // Begin checking for timeouts when progress starts, instead of loading,
  123. // to avoid timing out requests on browser concurrency queue
  124. timer.progress()
  125. if (ev.lengthComputable) {
  126. this.uppy.emit('upload-progress', file, {
  127. uploader: this,
  128. bytesUploaded: ev.loaded,
  129. bytesTotal: ev.total,
  130. })
  131. }
  132. })
  133. xhr.addEventListener('load', (ev) => {
  134. this.uppy.log(`[AwsS3/XHRUpload] ${id} finished`)
  135. timer.done()
  136. // eslint-disable-next-line no-use-before-define
  137. queuedRequest.done()
  138. if (this.uploaderEvents[file.id]) {
  139. this.uploaderEvents[file.id].remove()
  140. this.uploaderEvents[file.id] = null
  141. }
  142. if (opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  143. const body = opts.getResponseData(xhr.responseText, xhr)
  144. const uploadURL = body[opts.responseUrlFieldName]
  145. const uploadResp = {
  146. status: ev.target.status,
  147. body,
  148. uploadURL,
  149. }
  150. this.uppy.emit('upload-success', file, uploadResp)
  151. if (uploadURL) {
  152. this.uppy.log(`Download ${file.name} from ${uploadURL}`)
  153. }
  154. return resolve(file)
  155. }
  156. const body = opts.getResponseData(xhr.responseText, xhr)
  157. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  158. const response = {
  159. status: ev.target.status,
  160. body,
  161. }
  162. this.uppy.emit('upload-error', file, error, response)
  163. return reject(error)
  164. })
  165. xhr.addEventListener('error', () => {
  166. this.uppy.log(`[AwsS3/XHRUpload] ${id} errored`)
  167. timer.done()
  168. // eslint-disable-next-line no-use-before-define
  169. queuedRequest.done()
  170. if (this.uploaderEvents[file.id]) {
  171. this.uploaderEvents[file.id].remove()
  172. this.uploaderEvents[file.id] = null
  173. }
  174. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  175. this.uppy.emit('upload-error', file, error)
  176. return reject(error)
  177. })
  178. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  179. // IE10 does not allow setting `withCredentials` and `responseType`
  180. // before `open()` is called. It’s important to set withCredentials
  181. // to a boolean, otherwise React Native crashes
  182. xhr.withCredentials = Boolean(opts.withCredentials)
  183. if (opts.responseType !== '') {
  184. xhr.responseType = opts.responseType
  185. }
  186. Object.keys(opts.headers).forEach((header) => {
  187. xhr.setRequestHeader(header, opts.headers[header])
  188. })
  189. const queuedRequest = this.requests.run(() => {
  190. xhr.send(data)
  191. return () => {
  192. // eslint-disable-next-line no-use-before-define
  193. timer.done()
  194. xhr.abort()
  195. }
  196. }, { priority: 1 })
  197. this.#addEventHandlerForFile('file-removed', file.id, () => {
  198. queuedRequest.abort()
  199. reject(new Error('File removed'))
  200. })
  201. this.#addEventHandlerIfFileStillExists('cancel-all', file.id, ({ reason } = {}) => {
  202. if (reason === 'user') {
  203. queuedRequest.abort()
  204. }
  205. reject(new Error('Upload cancelled'))
  206. })
  207. })
  208. }
  209. #requestSocketToken = async (file) => {
  210. const opts = this.#getOptions(file)
  211. const Client = file.remote.providerOptions.provider ? Provider : RequestClient
  212. const client = new Client(this.uppy, file.remote.providerOptions)
  213. const allowedMetaFields = Array.isArray(opts.allowedMetaFields)
  214. ? opts.allowedMetaFields
  215. // Send along all fields by default.
  216. : Object.keys(file.meta)
  217. if (file.tus) {
  218. // Install file-specific upload overrides.
  219. Object.assign(opts, file.tus)
  220. }
  221. const res = await client.post(file.remote.url, {
  222. ...file.remote.body,
  223. protocol: 'multipart',
  224. endpoint: opts.endpoint,
  225. size: file.data.size,
  226. fieldname: opts.fieldName,
  227. metadata: Object.fromEntries(allowedMetaFields.map(name => [name, file.meta[name]])),
  228. httpMethod: opts.method,
  229. useFormData: opts.formData,
  230. headers: opts.headers,
  231. })
  232. return res.token
  233. }
  234. async #uploadRemoteFile (file) {
  235. try {
  236. if (file.serverToken) {
  237. return this.connectToServerSocket(file)
  238. }
  239. const serverToken = await this.#queueRequestSocketToken(file)
  240. this.uppy.setFileState(file.id, { serverToken })
  241. return this.connectToServerSocket(this.uppy.getFile(file.id))
  242. } catch (err) {
  243. this.uppy.emit('upload-error', file, err)
  244. throw err
  245. }
  246. }
  247. connectToServerSocket (file) {
  248. return new Promise((resolve, reject) => {
  249. const opts = this.#getOptions(file)
  250. const token = file.serverToken
  251. const host = getSocketHost(file.remote.companionUrl)
  252. const socket = new Socket({ target: `${host}/api/${token}` })
  253. this.uploaderEvents[file.id] = new EventTracker(this.uppy)
  254. const queuedRequest = this.requests.run(() => {
  255. if (file.isPaused) {
  256. socket.send('pause', {})
  257. }
  258. return () => socket.close()
  259. })
  260. this.#addEventHandlerForFile('file-removed', file.id, () => {
  261. socket.send('cancel', {})
  262. queuedRequest.abort()
  263. resolve(`upload ${file.id} was removed`)
  264. })
  265. this.#addEventHandlerIfFileStillExists('cancel-all', file.id, ({ reason } = {}) => {
  266. if (reason === 'user') {
  267. socket.send('cancel', {})
  268. queuedRequest.abort()
  269. }
  270. resolve(`upload ${file.id} was canceled`)
  271. })
  272. this.#addEventHandlerForFile('upload-retry', file.id, () => {
  273. socket.send('pause', {})
  274. socket.send('resume', {})
  275. })
  276. this.#addEventHandlerIfFileStillExists('retry-all', file.id, () => {
  277. socket.send('pause', {})
  278. socket.send('resume', {})
  279. })
  280. socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
  281. socket.on('success', (data) => {
  282. const body = opts.getResponseData(data.response.responseText, data.response)
  283. const uploadURL = body[opts.responseUrlFieldName]
  284. const uploadResp = {
  285. status: data.response.status,
  286. body,
  287. uploadURL,
  288. bytesUploaded: data.bytesUploaded,
  289. }
  290. this.uppy.emit('upload-success', file, uploadResp)
  291. queuedRequest.done()
  292. if (this.uploaderEvents[file.id]) {
  293. this.uploaderEvents[file.id].remove()
  294. this.uploaderEvents[file.id] = null
  295. }
  296. return resolve()
  297. })
  298. socket.on('error', (errData) => {
  299. const resp = errData.response
  300. const error = resp
  301. ? opts.getResponseError(resp.responseText, resp)
  302. : new ErrorWithCause(errData.error.message, { cause: errData.error })
  303. this.uppy.emit('upload-error', file, error)
  304. queuedRequest.done()
  305. if (this.uploaderEvents[file.id]) {
  306. this.uploaderEvents[file.id].remove()
  307. this.uploaderEvents[file.id] = null
  308. }
  309. reject(error)
  310. })
  311. }).catch((err) => {
  312. this.uppy.emit('upload-error', file, err)
  313. return Promise.reject(err)
  314. })
  315. }
  316. }