index.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. const { Plugin } = require('@uppy/core')
  2. const cuid = require('cuid')
  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 limitPromises = require('@uppy/utils/lib/limitPromises')
  9. function buildResponseError (xhr, error) {
  10. // No error message
  11. if (!error) error = new Error('Upload error')
  12. // Got an error message string
  13. if (typeof error === 'string') error = new Error(error)
  14. // Got something else
  15. if (!(error instanceof Error)) {
  16. error = Object.assign(new Error('Upload error'), { data: error })
  17. }
  18. error.request = xhr
  19. return error
  20. }
  21. /**
  22. * Set `data.type` in the blob to `file.meta.type`,
  23. * because we might have detected a more accurate file type in Uppy
  24. * https://stackoverflow.com/a/50875615
  25. *
  26. * @param {Object} file File object with `data`, `size` and `meta` properties
  27. * @returns {Object} blob updated with the new `type` set from `file.meta.type`
  28. */
  29. function setTypeInBlob (file) {
  30. const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type)
  31. return dataWithUpdatedType
  32. }
  33. module.exports = class XHRUpload extends Plugin {
  34. static VERSION = require('../package.json').version
  35. constructor (uppy, opts) {
  36. super(uppy, opts)
  37. this.type = 'uploader'
  38. this.id = this.opts.id || 'XHRUpload'
  39. this.title = 'XHRUpload'
  40. this.defaultLocale = {
  41. strings: {
  42. timedOut: 'Upload stalled for %{seconds} seconds, aborting.'
  43. }
  44. }
  45. // Default options
  46. const defaultOptions = {
  47. formData: true,
  48. fieldName: 'files[]',
  49. method: 'post',
  50. metaFields: null,
  51. responseUrlFieldName: 'url',
  52. bundle: false,
  53. headers: {},
  54. timeout: 30 * 1000,
  55. limit: 0,
  56. withCredentials: false,
  57. responseType: '',
  58. /**
  59. * @typedef respObj
  60. * @property {string} responseText
  61. * @property {number} status
  62. * @property {string} statusText
  63. * @property {Object.<string, string>} headers
  64. *
  65. * @param {string} responseText the response body string
  66. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  67. */
  68. getResponseData (responseText, response) {
  69. let parsedResponse = {}
  70. try {
  71. parsedResponse = JSON.parse(responseText)
  72. } catch (err) {
  73. console.log(err)
  74. }
  75. return parsedResponse
  76. },
  77. /**
  78. *
  79. * @param {string} responseText the response body string
  80. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  81. */
  82. getResponseError (responseText, response) {
  83. return new Error('Upload error')
  84. },
  85. /**
  86. * @param {number} status the response status code
  87. * @param {string} responseText the response body string
  88. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  89. */
  90. validateStatus (status, responseText, response) {
  91. return status >= 200 && status < 300
  92. }
  93. }
  94. // Merge default options with the ones set by user
  95. this.opts = Object.assign({}, defaultOptions, opts)
  96. // i18n
  97. this.translator = new Translator([ this.defaultLocale, this.uppy.locale, this.opts.locale ])
  98. this.i18n = this.translator.translate.bind(this.translator)
  99. this.i18nArray = this.translator.translateArray.bind(this.translator)
  100. this.handleUpload = this.handleUpload.bind(this)
  101. // Simultaneous upload limiting is shared across all uploads with this plugin.
  102. if (typeof this.opts.limit === 'number' && this.opts.limit !== 0) {
  103. this.limitUploads = limitPromises(this.opts.limit)
  104. } else {
  105. this.limitUploads = (fn) => fn
  106. }
  107. if (this.opts.bundle && !this.opts.formData) {
  108. throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.')
  109. }
  110. }
  111. getOptions (file) {
  112. const overrides = this.uppy.getState().xhrUpload
  113. const opts = {
  114. ...this.opts,
  115. ...(overrides || {}),
  116. ...(file.xhrUpload || {}),
  117. headers: {}
  118. }
  119. Object.assign(opts.headers, this.opts.headers)
  120. if (overrides) {
  121. Object.assign(opts.headers, overrides.headers)
  122. }
  123. if (file.xhrUpload) {
  124. Object.assign(opts.headers, file.xhrUpload.headers)
  125. }
  126. return opts
  127. }
  128. // Helper to abort upload requests if there has not been any progress for `timeout` ms.
  129. // Create an instance using `timer = createProgressTimeout(10000, onTimeout)`
  130. // Call `timer.progress()` to signal that there has been progress of any kind.
  131. // Call `timer.done()` when the upload has completed.
  132. createProgressTimeout (timeout, timeoutHandler) {
  133. const uppy = this.uppy
  134. const self = this
  135. let isDone = false
  136. function onTimedOut () {
  137. uppy.log(`[XHRUpload] timed out`)
  138. const error = new Error(self.i18n('timedOut', { seconds: Math.ceil(timeout / 1000) }))
  139. timeoutHandler(error)
  140. }
  141. let aliveTimer = null
  142. function progress () {
  143. // Some browsers fire another progress event when the upload is
  144. // cancelled, so we have to ignore progress after the timer was
  145. // told to stop.
  146. if (isDone) return
  147. if (timeout > 0) {
  148. if (aliveTimer) clearTimeout(aliveTimer)
  149. aliveTimer = setTimeout(onTimedOut, timeout)
  150. }
  151. }
  152. function done () {
  153. uppy.log(`[XHRUpload] timer done`)
  154. if (aliveTimer) {
  155. clearTimeout(aliveTimer)
  156. aliveTimer = null
  157. }
  158. isDone = true
  159. }
  160. return {
  161. progress,
  162. done
  163. }
  164. }
  165. addMetadata (formData, meta, opts) {
  166. const metaFields = Array.isArray(opts.metaFields)
  167. ? opts.metaFields
  168. // Send along all fields by default.
  169. : Object.keys(meta)
  170. metaFields.forEach((item) => {
  171. formData.append(item, meta[item])
  172. })
  173. }
  174. createFormDataUpload (file, opts) {
  175. const formPost = new FormData()
  176. this.addMetadata(formPost, file.meta, opts)
  177. const dataWithUpdatedType = setTypeInBlob(file)
  178. if (file.name) {
  179. formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name)
  180. } else {
  181. formPost.append(opts.fieldName, dataWithUpdatedType)
  182. }
  183. return formPost
  184. }
  185. createBundledUpload (files, opts) {
  186. const formPost = new FormData()
  187. const { meta } = this.uppy.getState()
  188. this.addMetadata(formPost, meta, opts)
  189. files.forEach((file) => {
  190. const opts = this.getOptions(file)
  191. const dataWithUpdatedType = setTypeInBlob(file)
  192. if (file.name) {
  193. formPost.append(opts.fieldName, dataWithUpdatedType, file.name)
  194. } else {
  195. formPost.append(opts.fieldName, dataWithUpdatedType)
  196. }
  197. })
  198. return formPost
  199. }
  200. createBareUpload (file, opts) {
  201. return file.data
  202. }
  203. upload (file, current, total) {
  204. const opts = this.getOptions(file)
  205. this.uppy.log(`uploading ${current} of ${total}`)
  206. return new Promise((resolve, reject) => {
  207. const data = opts.formData
  208. ? this.createFormDataUpload(file, opts)
  209. : this.createBareUpload(file, opts)
  210. const timer = this.createProgressTimeout(opts.timeout, (error) => {
  211. xhr.abort()
  212. this.uppy.emit('upload-error', file, error)
  213. reject(error)
  214. })
  215. const xhr = new XMLHttpRequest()
  216. const id = cuid()
  217. xhr.upload.addEventListener('loadstart', (ev) => {
  218. this.uppy.log(`[XHRUpload] ${id} started`)
  219. })
  220. xhr.upload.addEventListener('progress', (ev) => {
  221. this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  222. // Begin checking for timeouts when progress starts, instead of loading,
  223. // to avoid timing out requests on browser concurrency queue
  224. timer.progress()
  225. if (ev.lengthComputable) {
  226. this.uppy.emit('upload-progress', file, {
  227. uploader: this,
  228. bytesUploaded: ev.loaded,
  229. bytesTotal: ev.total
  230. })
  231. }
  232. })
  233. xhr.addEventListener('load', (ev) => {
  234. this.uppy.log(`[XHRUpload] ${id} finished`)
  235. timer.done()
  236. if (opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  237. const body = opts.getResponseData(xhr.responseText, xhr)
  238. const uploadURL = body[opts.responseUrlFieldName]
  239. const uploadResp = {
  240. status: ev.target.status,
  241. body,
  242. uploadURL
  243. }
  244. this.uppy.emit('upload-success', file, uploadResp)
  245. if (uploadURL) {
  246. this.uppy.log(`Download ${file.name} from ${uploadURL}`)
  247. }
  248. return resolve(file)
  249. } else {
  250. const body = opts.getResponseData(xhr.responseText, xhr)
  251. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  252. const response = {
  253. status: ev.target.status,
  254. body
  255. }
  256. this.uppy.emit('upload-error', file, error, response)
  257. return reject(error)
  258. }
  259. })
  260. xhr.addEventListener('error', (ev) => {
  261. this.uppy.log(`[XHRUpload] ${id} errored`)
  262. timer.done()
  263. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  264. this.uppy.emit('upload-error', file, error)
  265. return reject(error)
  266. })
  267. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  268. // IE10 does not allow setting `withCredentials` and `responseType`
  269. // before `open()` is called.
  270. xhr.withCredentials = opts.withCredentials
  271. if (opts.responseType !== '') {
  272. xhr.responseType = opts.responseType
  273. }
  274. Object.keys(opts.headers).forEach((header) => {
  275. xhr.setRequestHeader(header, opts.headers[header])
  276. })
  277. xhr.send(data)
  278. this.uppy.on('file-removed', (removedFile) => {
  279. if (removedFile.id === file.id) {
  280. timer.done()
  281. xhr.abort()
  282. reject(new Error('File removed'))
  283. }
  284. })
  285. this.uppy.on('cancel-all', () => {
  286. timer.done()
  287. xhr.abort()
  288. reject(new Error('Upload cancelled'))
  289. })
  290. })
  291. }
  292. uploadRemote (file, current, total) {
  293. const opts = this.getOptions(file)
  294. return new Promise((resolve, reject) => {
  295. const fields = {}
  296. const metaFields = Array.isArray(opts.metaFields)
  297. ? opts.metaFields
  298. // Send along all fields by default.
  299. : Object.keys(file.meta)
  300. metaFields.forEach((name) => {
  301. fields[name] = file.meta[name]
  302. })
  303. const Client = file.remote.providerOptions.provider ? Provider : RequestClient
  304. const client = new Client(this.uppy, file.remote.providerOptions)
  305. client.post(file.remote.url, {
  306. ...file.remote.body,
  307. endpoint: opts.endpoint,
  308. size: file.data.size,
  309. fieldname: opts.fieldName,
  310. metadata: fields,
  311. headers: opts.headers
  312. }).then((res) => {
  313. const token = res.token
  314. const host = getSocketHost(file.remote.companionUrl)
  315. const socket = new Socket({ target: `${host}/api/${token}` })
  316. socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
  317. socket.on('success', (data) => {
  318. const body = opts.getResponseData(data.response.responseText, data.response)
  319. const uploadURL = body[opts.responseUrlFieldName]
  320. const uploadResp = {
  321. status: data.response.status,
  322. body,
  323. uploadURL
  324. }
  325. this.uppy.emit('upload-success', file, uploadResp)
  326. socket.close()
  327. return resolve()
  328. })
  329. socket.on('error', (errData) => {
  330. const resp = errData.response
  331. const error = resp
  332. ? opts.getResponseError(resp.responseText, resp)
  333. : Object.assign(new Error(errData.error.message), { cause: errData.error })
  334. this.uppy.emit('upload-error', file, error)
  335. reject(error)
  336. })
  337. })
  338. })
  339. }
  340. uploadBundle (files) {
  341. return new Promise((resolve, reject) => {
  342. const endpoint = this.opts.endpoint
  343. const method = this.opts.method
  344. const optsFromState = this.uppy.getState().xhrUpload
  345. const formData = this.createBundledUpload(files, {
  346. ...this.opts,
  347. ...(optsFromState || {})
  348. })
  349. const xhr = new XMLHttpRequest()
  350. const timer = this.createProgressTimeout(this.opts.timeout, (error) => {
  351. xhr.abort()
  352. emitError(error)
  353. reject(error)
  354. })
  355. const emitError = (error) => {
  356. files.forEach((file) => {
  357. this.uppy.emit('upload-error', file, error)
  358. })
  359. }
  360. xhr.upload.addEventListener('loadstart', (ev) => {
  361. this.uppy.log('[XHRUpload] started uploading bundle')
  362. timer.progress()
  363. })
  364. xhr.upload.addEventListener('progress', (ev) => {
  365. timer.progress()
  366. if (!ev.lengthComputable) return
  367. files.forEach((file) => {
  368. this.uppy.emit('upload-progress', file, {
  369. uploader: this,
  370. bytesUploaded: ev.loaded / ev.total * file.size,
  371. bytesTotal: file.size
  372. })
  373. })
  374. })
  375. xhr.addEventListener('load', (ev) => {
  376. timer.done()
  377. if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  378. const body = this.opts.getResponseData(xhr.responseText, xhr)
  379. const uploadResp = {
  380. status: ev.target.status,
  381. body
  382. }
  383. files.forEach((file) => {
  384. this.uppy.emit('upload-success', file, uploadResp)
  385. })
  386. return resolve()
  387. }
  388. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  389. error.request = xhr
  390. emitError(error)
  391. return reject(error)
  392. })
  393. xhr.addEventListener('error', (ev) => {
  394. timer.done()
  395. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  396. emitError(error)
  397. return reject(error)
  398. })
  399. this.uppy.on('cancel-all', () => {
  400. timer.done()
  401. xhr.abort()
  402. })
  403. xhr.open(method.toUpperCase(), endpoint, true)
  404. // IE10 does not allow setting `withCredentials` and `responseType`
  405. // before `open()` is called.
  406. xhr.withCredentials = this.opts.withCredentials
  407. if (this.opts.responseType !== '') {
  408. xhr.responseType = this.opts.responseType
  409. }
  410. Object.keys(this.opts.headers).forEach((header) => {
  411. xhr.setRequestHeader(header, this.opts.headers[header])
  412. })
  413. xhr.send(formData)
  414. files.forEach((file) => {
  415. this.uppy.emit('upload-started', file)
  416. })
  417. })
  418. }
  419. uploadFiles (files) {
  420. const actions = files.map((file, i) => {
  421. const current = parseInt(i, 10) + 1
  422. const total = files.length
  423. if (file.error) {
  424. return () => Promise.reject(new Error(file.error))
  425. } else if (file.isRemote) {
  426. // We emit upload-started here, so that it's also emitted for files
  427. // that have to wait due to the `limit` option.
  428. this.uppy.emit('upload-started', file)
  429. return this.uploadRemote.bind(this, file, current, total)
  430. } else {
  431. this.uppy.emit('upload-started', file)
  432. return this.upload.bind(this, file, current, total)
  433. }
  434. })
  435. const promises = actions.map((action) => {
  436. const limitedAction = this.limitUploads(action)
  437. return limitedAction()
  438. })
  439. return settle(promises)
  440. }
  441. handleUpload (fileIDs) {
  442. if (fileIDs.length === 0) {
  443. this.uppy.log('[XHRUpload] No files to upload!')
  444. return Promise.resolve()
  445. }
  446. this.uppy.log('[XHRUpload] Uploading...')
  447. const files = fileIDs.map((fileID) => this.uppy.getFile(fileID))
  448. if (this.opts.bundle) {
  449. return this.uploadBundle(files)
  450. }
  451. return this.uploadFiles(files).then(() => null)
  452. }
  453. install () {
  454. if (this.opts.bundle) {
  455. const { capabilities } = this.uppy.getState()
  456. this.uppy.setState({
  457. capabilities: {
  458. ...capabilities,
  459. individualCancellation: false
  460. }
  461. })
  462. }
  463. this.uppy.addUploader(this.handleUpload)
  464. }
  465. uninstall () {
  466. if (this.opts.bundle) {
  467. const { capabilities } = this.uppy.getState()
  468. this.uppy.setState({
  469. capabilities: {
  470. ...capabilities,
  471. individualCancellation: true
  472. }
  473. })
  474. }
  475. this.uppy.removeUploader(this.handleUpload)
  476. }
  477. }