index.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. const { BasePlugin } = require('@uppy/core')
  2. const { nanoid } = require('nanoid')
  3. const Translator = require('@uppy/utils/lib/Translator')
  4. const { Provider, RequestClient, Socket } = require('@uppy/companion-client')
  5. const emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress')
  6. const getSocketHost = require('@uppy/utils/lib/getSocketHost')
  7. const settle = require('@uppy/utils/lib/settle')
  8. const EventTracker = require('@uppy/utils/lib/EventTracker')
  9. const ProgressTimeout = require('@uppy/utils/lib/ProgressTimeout')
  10. const { RateLimitedQueue, internalRateLimitedQueue } = require('@uppy/utils/lib/RateLimitedQueue')
  11. const NetworkError = require('@uppy/utils/lib/NetworkError')
  12. const isNetworkError = require('@uppy/utils/lib/isNetworkError')
  13. function buildResponseError (xhr, err) {
  14. let error = err
  15. // No error message
  16. if (!error) error = new Error('Upload error')
  17. // Got an error message string
  18. if (typeof error === 'string') error = new Error(error)
  19. // Got something else
  20. if (!(error instanceof Error)) {
  21. error = Object.assign(new Error('Upload error'), { data: error })
  22. }
  23. if (isNetworkError(xhr)) {
  24. error = new NetworkError(error, xhr)
  25. return error
  26. }
  27. error.request = xhr
  28. return error
  29. }
  30. /**
  31. * Set `data.type` in the blob to `file.meta.type`,
  32. * because we might have detected a more accurate file type in Uppy
  33. * https://stackoverflow.com/a/50875615
  34. *
  35. * @param {object} file File object with `data`, `size` and `meta` properties
  36. * @returns {object} blob updated with the new `type` set from `file.meta.type`
  37. */
  38. function setTypeInBlob (file) {
  39. const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type)
  40. return dataWithUpdatedType
  41. }
  42. module.exports = class XHRUpload extends BasePlugin {
  43. // eslint-disable-next-line global-require
  44. static VERSION = require('../package.json').version
  45. constructor (uppy, opts) {
  46. super(uppy, opts)
  47. this.type = 'uploader'
  48. this.id = this.opts.id || 'XHRUpload'
  49. this.title = 'XHRUpload'
  50. this.defaultLocale = {
  51. strings: {
  52. timedOut: 'Upload stalled for %{seconds} seconds, aborting.',
  53. },
  54. }
  55. // Default options
  56. const defaultOptions = {
  57. formData: true,
  58. fieldName: opts.bundle ? 'files[]' : 'file',
  59. method: 'post',
  60. metaFields: null,
  61. responseUrlFieldName: 'url',
  62. bundle: false,
  63. headers: {},
  64. timeout: 30 * 1000,
  65. limit: 5,
  66. withCredentials: false,
  67. responseType: '',
  68. /**
  69. * @typedef respObj
  70. * @property {string} responseText
  71. * @property {number} status
  72. * @property {string} statusText
  73. * @property {object.<string, string>} headers
  74. *
  75. * @param {string} responseText the response body string
  76. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  77. */
  78. getResponseData (responseText) {
  79. let parsedResponse = {}
  80. try {
  81. parsedResponse = JSON.parse(responseText)
  82. } catch (err) {
  83. this.uppy.log(err)
  84. }
  85. return parsedResponse
  86. },
  87. /**
  88. *
  89. * @param {string} responseText the response body string
  90. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  91. */
  92. getResponseError (_, response) {
  93. let error = new Error('Upload error')
  94. if (isNetworkError(response)) {
  95. error = new NetworkError(error, response)
  96. }
  97. return error
  98. },
  99. /**
  100. * Check if the response from the upload endpoint indicates that the upload was successful.
  101. *
  102. * @param {number} status the response status code
  103. */
  104. validateStatus (status) {
  105. return status >= 200 && status < 300
  106. },
  107. }
  108. this.opts = { ...defaultOptions, ...opts }
  109. this.handleUpload = this.handleUpload.bind(this)
  110. // Simultaneous upload limiting is shared across all uploads with this plugin.
  111. if (internalRateLimitedQueue in this.opts) {
  112. this.requests = this.opts[internalRateLimitedQueue]
  113. } else {
  114. this.requests = new RateLimitedQueue(this.opts.limit)
  115. }
  116. if (this.opts.bundle && !this.opts.formData) {
  117. throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.')
  118. }
  119. this.uploaderEvents = Object.create(null)
  120. }
  121. getOptions (file) {
  122. const overrides = this.uppy.getState().xhrUpload
  123. const { headers } = this.opts
  124. const opts = {
  125. ...this.opts,
  126. ...(overrides || {}),
  127. ...(file.xhrUpload || {}),
  128. headers: {},
  129. }
  130. // Support for `headers` as a function, only in the XHRUpload settings.
  131. // Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
  132. //
  133. // ```js
  134. // headers: (file) => ({ expires: file.meta.expires })
  135. // ```
  136. if (typeof headers === 'function') {
  137. opts.headers = headers(file)
  138. } else {
  139. Object.assign(opts.headers, this.opts.headers)
  140. }
  141. if (overrides) {
  142. Object.assign(opts.headers, overrides.headers)
  143. }
  144. if (file.xhrUpload) {
  145. Object.assign(opts.headers, file.xhrUpload.headers)
  146. }
  147. return opts
  148. }
  149. // eslint-disable-next-line class-methods-use-this
  150. addMetadata (formData, meta, opts) {
  151. const metaFields = Array.isArray(opts.metaFields)
  152. ? opts.metaFields
  153. : Object.keys(meta) // Send along all fields by default.
  154. metaFields.forEach((item) => {
  155. formData.append(item, meta[item])
  156. })
  157. }
  158. createFormDataUpload (file, opts) {
  159. const formPost = new FormData()
  160. this.addMetadata(formPost, file.meta, opts)
  161. const dataWithUpdatedType = setTypeInBlob(file)
  162. if (file.name) {
  163. formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name)
  164. } else {
  165. formPost.append(opts.fieldName, dataWithUpdatedType)
  166. }
  167. return formPost
  168. }
  169. createBundledUpload (files, opts) {
  170. const formPost = new FormData()
  171. const { meta } = this.uppy.getState()
  172. this.addMetadata(formPost, meta, opts)
  173. files.forEach((file) => {
  174. const options = this.getOptions(file)
  175. const dataWithUpdatedType = setTypeInBlob(file)
  176. if (file.name) {
  177. formPost.append(options.fieldName, dataWithUpdatedType, file.name)
  178. } else {
  179. formPost.append(options.fieldName, dataWithUpdatedType)
  180. }
  181. })
  182. return formPost
  183. }
  184. upload (file, current, total) {
  185. const opts = this.getOptions(file)
  186. this.uppy.log(`uploading ${current} of ${total}`)
  187. return new Promise((resolve, reject) => {
  188. this.uppy.emit('upload-started', file)
  189. const data = opts.formData
  190. ? this.createFormDataUpload(file, opts)
  191. : file.data
  192. const xhr = new XMLHttpRequest()
  193. this.uploaderEvents[file.id] = new EventTracker(this.uppy)
  194. const timer = new ProgressTimeout(opts.timeout, () => {
  195. xhr.abort()
  196. queuedRequest.done()
  197. const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(opts.timeout / 1000) }))
  198. this.uppy.emit('upload-error', file, error)
  199. reject(error)
  200. })
  201. const id = nanoid()
  202. xhr.upload.addEventListener('loadstart', () => {
  203. this.uppy.log(`[XHRUpload] ${id} started`)
  204. })
  205. xhr.upload.addEventListener('progress', (ev) => {
  206. this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  207. // Begin checking for timeouts when progress starts, instead of loading,
  208. // to avoid timing out requests on browser concurrency queue
  209. timer.progress()
  210. if (ev.lengthComputable) {
  211. this.uppy.emit('upload-progress', file, {
  212. uploader: this,
  213. bytesUploaded: ev.loaded,
  214. bytesTotal: ev.total,
  215. })
  216. }
  217. })
  218. xhr.addEventListener('load', (ev) => {
  219. this.uppy.log(`[XHRUpload] ${id} finished`)
  220. timer.done()
  221. queuedRequest.done()
  222. if (this.uploaderEvents[file.id]) {
  223. this.uploaderEvents[file.id].remove()
  224. this.uploaderEvents[file.id] = null
  225. }
  226. if (opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  227. const body = opts.getResponseData(xhr.responseText, xhr)
  228. const uploadURL = body[opts.responseUrlFieldName]
  229. const uploadResp = {
  230. status: ev.target.status,
  231. body,
  232. uploadURL,
  233. }
  234. this.uppy.emit('upload-success', file, uploadResp)
  235. if (uploadURL) {
  236. this.uppy.log(`Download ${file.name} from ${uploadURL}`)
  237. }
  238. return resolve(file)
  239. }
  240. const body = opts.getResponseData(xhr.responseText, xhr)
  241. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  242. const response = {
  243. status: ev.target.status,
  244. body,
  245. }
  246. this.uppy.emit('upload-error', file, error, response)
  247. return reject(error)
  248. })
  249. xhr.addEventListener('error', () => {
  250. this.uppy.log(`[XHRUpload] ${id} errored`)
  251. timer.done()
  252. queuedRequest.done()
  253. if (this.uploaderEvents[file.id]) {
  254. this.uploaderEvents[file.id].remove()
  255. this.uploaderEvents[file.id] = null
  256. }
  257. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  258. this.uppy.emit('upload-error', file, error)
  259. return reject(error)
  260. })
  261. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  262. // IE10 does not allow setting `withCredentials` and `responseType`
  263. // before `open()` is called.
  264. xhr.withCredentials = opts.withCredentials
  265. if (opts.responseType !== '') {
  266. xhr.responseType = opts.responseType
  267. }
  268. const queuedRequest = this.requests.run(() => {
  269. this.uppy.emit('upload-started', file)
  270. // When using an authentication system like JWT, the bearer token goes as a header. This
  271. // header needs to be fresh each time the token is refreshed so computing and setting the
  272. // headers just before the upload starts enables this kind of authentication to work properly.
  273. // Otherwise, half-way through the list of uploads the token could be stale and the upload would fail.
  274. const currentOpts = this.getOptions(file)
  275. Object.keys(currentOpts.headers).forEach((header) => {
  276. xhr.setRequestHeader(header, currentOpts.headers[header])
  277. })
  278. xhr.send(data)
  279. return () => {
  280. timer.done()
  281. xhr.abort()
  282. }
  283. })
  284. this.onFileRemove(file.id, () => {
  285. queuedRequest.abort()
  286. reject(new Error('File removed'))
  287. })
  288. this.onCancelAll(file.id, () => {
  289. queuedRequest.abort()
  290. reject(new Error('Upload cancelled'))
  291. })
  292. })
  293. }
  294. uploadRemote (file) {
  295. const opts = this.getOptions(file)
  296. return new Promise((resolve, reject) => {
  297. const fields = {}
  298. const metaFields = Array.isArray(opts.metaFields)
  299. ? opts.metaFields
  300. // Send along all fields by default.
  301. : Object.keys(file.meta)
  302. metaFields.forEach((name) => {
  303. fields[name] = file.meta[name]
  304. })
  305. const Client = file.remote.providerOptions.provider ? Provider : RequestClient
  306. const client = new Client(this.uppy, file.remote.providerOptions)
  307. client.post(file.remote.url, {
  308. ...file.remote.body,
  309. endpoint: opts.endpoint,
  310. size: file.data.size,
  311. fieldname: opts.fieldName,
  312. metadata: fields,
  313. httpMethod: opts.method,
  314. useFormData: opts.formData,
  315. headers: opts.headers,
  316. }).then((res) => {
  317. const { token } = res
  318. const host = getSocketHost(file.remote.companionUrl)
  319. const socket = new Socket({ target: `${host}/api/${token}`, autoOpen: false })
  320. this.uploaderEvents[file.id] = new EventTracker(this.uppy)
  321. this.onFileRemove(file.id, () => {
  322. socket.send('pause', {})
  323. queuedRequest.abort()
  324. resolve(`upload ${file.id} was removed`)
  325. })
  326. this.onCancelAll(file.id, () => {
  327. socket.send('pause', {})
  328. queuedRequest.abort()
  329. resolve(`upload ${file.id} was canceled`)
  330. })
  331. this.onRetry(file.id, () => {
  332. socket.send('pause', {})
  333. socket.send('resume', {})
  334. })
  335. this.onRetryAll(file.id, () => {
  336. socket.send('pause', {})
  337. socket.send('resume', {})
  338. })
  339. socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
  340. socket.on('success', (data) => {
  341. const body = opts.getResponseData(data.response.responseText, data.response)
  342. const uploadURL = body[opts.responseUrlFieldName]
  343. const uploadResp = {
  344. status: data.response.status,
  345. body,
  346. uploadURL,
  347. }
  348. this.uppy.emit('upload-success', file, uploadResp)
  349. queuedRequest.done()
  350. if (this.uploaderEvents[file.id]) {
  351. this.uploaderEvents[file.id].remove()
  352. this.uploaderEvents[file.id] = null
  353. }
  354. return resolve()
  355. })
  356. socket.on('error', (errData) => {
  357. const resp = errData.response
  358. const error = resp
  359. ? opts.getResponseError(resp.responseText, resp)
  360. : Object.assign(new Error(errData.error.message), { cause: errData.error })
  361. this.uppy.emit('upload-error', file, error)
  362. queuedRequest.done()
  363. if (this.uploaderEvents[file.id]) {
  364. this.uploaderEvents[file.id].remove()
  365. this.uploaderEvents[file.id] = null
  366. }
  367. reject(error)
  368. })
  369. const queuedRequest = this.requests.run(() => {
  370. socket.open()
  371. if (file.isPaused) {
  372. socket.send('pause', {})
  373. }
  374. return () => socket.close()
  375. })
  376. }).catch((err) => {
  377. this.uppy.emit('upload-error', file, err)
  378. reject(err)
  379. })
  380. })
  381. }
  382. uploadBundle (files) {
  383. return new Promise((resolve, reject) => {
  384. const { endpoint } = this.opts
  385. const { method } = this.opts
  386. const optsFromState = this.uppy.getState().xhrUpload
  387. const formData = this.createBundledUpload(files, {
  388. ...this.opts,
  389. ...(optsFromState || {}),
  390. })
  391. const xhr = new XMLHttpRequest()
  392. const timer = new ProgressTimeout(this.opts.timeout, () => {
  393. xhr.abort()
  394. const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(this.opts.timeout / 1000) }))
  395. emitError(error)
  396. reject(error)
  397. })
  398. const emitError = (error) => {
  399. files.forEach((file) => {
  400. this.uppy.emit('upload-error', file, error)
  401. })
  402. }
  403. xhr.upload.addEventListener('loadstart', () => {
  404. this.uppy.log('[XHRUpload] started uploading bundle')
  405. timer.progress()
  406. })
  407. xhr.upload.addEventListener('progress', (ev) => {
  408. timer.progress()
  409. if (!ev.lengthComputable) return
  410. files.forEach((file) => {
  411. this.uppy.emit('upload-progress', file, {
  412. uploader: this,
  413. bytesUploaded: ev.loaded / ev.total * file.size,
  414. bytesTotal: file.size,
  415. })
  416. })
  417. })
  418. xhr.addEventListener('load', (ev) => {
  419. timer.done()
  420. if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  421. const body = this.opts.getResponseData(xhr.responseText, xhr)
  422. const uploadResp = {
  423. status: ev.target.status,
  424. body,
  425. }
  426. files.forEach((file) => {
  427. this.uppy.emit('upload-success', file, uploadResp)
  428. })
  429. return resolve()
  430. }
  431. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  432. error.request = xhr
  433. emitError(error)
  434. return reject(error)
  435. })
  436. xhr.addEventListener('error', () => {
  437. timer.done()
  438. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  439. emitError(error)
  440. return reject(error)
  441. })
  442. this.uppy.on('cancel-all', () => {
  443. timer.done()
  444. xhr.abort()
  445. })
  446. xhr.open(method.toUpperCase(), endpoint, true)
  447. // IE10 does not allow setting `withCredentials` and `responseType`
  448. // before `open()` is called.
  449. xhr.withCredentials = this.opts.withCredentials
  450. if (this.opts.responseType !== '') {
  451. xhr.responseType = this.opts.responseType
  452. }
  453. Object.keys(this.opts.headers).forEach((header) => {
  454. xhr.setRequestHeader(header, this.opts.headers[header])
  455. })
  456. xhr.send(formData)
  457. files.forEach((file) => {
  458. this.uppy.emit('upload-started', file)
  459. })
  460. })
  461. }
  462. uploadFiles (files) {
  463. const promises = files.map((file, i) => {
  464. const current = parseInt(i, 10) + 1
  465. const total = files.length
  466. if (file.error) {
  467. return Promise.reject(new Error(file.error))
  468. } if (file.isRemote) {
  469. return this.uploadRemote(file, current, total)
  470. }
  471. return this.upload(file, current, total)
  472. })
  473. return settle(promises)
  474. }
  475. onFileRemove (fileID, cb) {
  476. this.uploaderEvents[fileID].on('file-removed', (file) => {
  477. if (fileID === file.id) cb(file.id)
  478. })
  479. }
  480. onRetry (fileID, cb) {
  481. this.uploaderEvents[fileID].on('upload-retry', (targetFileID) => {
  482. if (fileID === targetFileID) {
  483. cb()
  484. }
  485. })
  486. }
  487. onRetryAll (fileID, cb) {
  488. this.uploaderEvents[fileID].on('retry-all', () => {
  489. if (!this.uppy.getFile(fileID)) return
  490. cb()
  491. })
  492. }
  493. onCancelAll (fileID, cb) {
  494. this.uploaderEvents[fileID].on('cancel-all', () => {
  495. if (!this.uppy.getFile(fileID)) return
  496. cb()
  497. })
  498. }
  499. handleUpload (fileIDs) {
  500. if (fileIDs.length === 0) {
  501. this.uppy.log('[XHRUpload] No files to upload!')
  502. return Promise.resolve()
  503. }
  504. // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin
  505. // (basically just AwsS3) using the internal symbol
  506. if (this.opts.limit === 0 && !this.opts[internalRateLimitedQueue]) {
  507. this.uppy.log(
  508. '[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0',
  509. 'warning'
  510. )
  511. }
  512. this.uppy.log('[XHRUpload] Uploading...')
  513. const files = fileIDs.map((fileID) => this.uppy.getFile(fileID))
  514. if (this.opts.bundle) {
  515. // if bundle: true, we don’t support remote uploads
  516. const isSomeFileRemote = files.some(file => file.isRemote)
  517. if (isSomeFileRemote) {
  518. throw new Error('Can’t upload remote files when the `bundle: true` option is set')
  519. }
  520. if (typeof this.opts.headers === 'function') {
  521. throw new TypeError('`headers` may not be a function when the `bundle: true` option is set')
  522. }
  523. return this.uploadBundle(files)
  524. }
  525. return this.uploadFiles(files).then(() => null)
  526. }
  527. install () {
  528. if (this.opts.bundle) {
  529. const { capabilities } = this.uppy.getState()
  530. this.uppy.setState({
  531. capabilities: {
  532. ...capabilities,
  533. individualCancellation: false,
  534. },
  535. })
  536. }
  537. this.uppy.addUploader(this.handleUpload)
  538. }
  539. uninstall () {
  540. if (this.opts.bundle) {
  541. const { capabilities } = this.uppy.getState()
  542. this.uppy.setState({
  543. capabilities: {
  544. ...capabilities,
  545. individualCancellation: true,
  546. },
  547. })
  548. }
  549. this.uppy.removeUploader(this.handleUpload)
  550. }
  551. }