index.js 21 KB

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