XHRUpload.js 14 KB

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