XHRUpload.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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 {
  6. emitSocketProgress,
  7. getSocketHost,
  8. settle,
  9. limitPromises
  10. } = require('../core/Utils')
  11. function buildResponseError (xhr, error) {
  12. // No error message
  13. if (!error) error = new Error('Upload error')
  14. // Got an error message string
  15. if (typeof error === 'string') error = new Error(error)
  16. // Got something else
  17. if (!(error instanceof Error)) {
  18. error = Object.assign(new Error('Upload error'), { data: error })
  19. }
  20. error.request = xhr
  21. return error
  22. }
  23. module.exports = class XHRUpload extends Plugin {
  24. constructor (uppy, opts) {
  25. super(uppy, opts)
  26. this.type = 'uploader'
  27. this.id = 'XHRUpload'
  28. this.title = 'XHRUpload'
  29. const defaultLocale = {
  30. strings: {
  31. timedOut: 'Upload stalled for %{seconds} seconds, aborting.'
  32. }
  33. }
  34. // Default options
  35. const defaultOptions = {
  36. formData: true,
  37. fieldName: 'files[]',
  38. method: 'post',
  39. metaFields: null,
  40. responseUrlFieldName: 'url',
  41. bundle: false,
  42. headers: {},
  43. locale: defaultLocale,
  44. timeout: 30 * 1000,
  45. limit: 0,
  46. /**
  47. * @typedef respObj
  48. * @property {string} responseText
  49. * @property {number} status
  50. * @property {string} statusText
  51. * @property {Object.<string, string>} headers
  52. *
  53. * @param {string} responseText the response body string
  54. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  55. */
  56. getResponseData (responseText, response) {
  57. let parsedResponse = {}
  58. try {
  59. parsedResponse = JSON.parse(responseText)
  60. } catch (err) {
  61. console.log(err)
  62. }
  63. return parsedResponse
  64. },
  65. /**
  66. *
  67. * @param {string} responseText the response body string
  68. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  69. */
  70. getResponseError (responseText, response) {
  71. return new Error('Upload error')
  72. }
  73. }
  74. // Merge default options with the ones set by user
  75. this.opts = Object.assign({}, defaultOptions, opts)
  76. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  77. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  78. // i18n
  79. this.translator = new Translator({ locale: this.locale })
  80. this.i18n = this.translator.translate.bind(this.translator)
  81. this.handleUpload = this.handleUpload.bind(this)
  82. // Simultaneous upload limiting is shared across all uploads with this plugin.
  83. if (typeof this.opts.limit === 'number' && this.opts.limit !== 0) {
  84. this.limitUploads = limitPromises(this.opts.limit)
  85. } else {
  86. this.limitUploads = (fn) => fn
  87. }
  88. if (this.opts.bundle && !this.opts.formData) {
  89. throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.')
  90. }
  91. }
  92. getOptions (file) {
  93. const opts = Object.assign({},
  94. this.opts,
  95. this.uppy.state.xhrUpload || {},
  96. file.xhrUpload || {}
  97. )
  98. opts.headers = {}
  99. Object.assign(opts.headers, this.opts.headers)
  100. if (this.uppy.state.xhrUpload) {
  101. Object.assign(opts.headers, this.uppy.state.xhrUpload.headers)
  102. }
  103. if (file.xhrUpload) {
  104. Object.assign(opts.headers, file.xhrUpload.headers)
  105. }
  106. return opts
  107. }
  108. // Helper to abort upload requests if there has not been any progress for `timeout` ms.
  109. // Create an instance using `timer = createProgressTimeout(10000, onTimeout)`
  110. // Call `timer.progress()` to signal that there has been progress of any kind.
  111. // Call `timer.done()` when the upload has completed.
  112. createProgressTimeout (timeout, timeoutHandler) {
  113. const uppy = this.uppy
  114. const self = this
  115. function onTimedOut () {
  116. uppy.log(`[XHRUpload] timed out`)
  117. const error = new Error(self.i18n('timedOut', { seconds: Math.ceil(timeout / 1000) }))
  118. timeoutHandler(error)
  119. }
  120. let aliveTimer = null
  121. function progress () {
  122. if (timeout > 0) {
  123. done()
  124. aliveTimer = setTimeout(onTimedOut, timeout)
  125. }
  126. }
  127. function done () {
  128. if (aliveTimer) {
  129. clearTimeout(aliveTimer)
  130. aliveTimer = null
  131. }
  132. }
  133. return {
  134. progress,
  135. done
  136. }
  137. }
  138. createFormDataUpload (file, opts) {
  139. const formPost = new FormData()
  140. const metaFields = Array.isArray(opts.metaFields)
  141. ? opts.metaFields
  142. // Send along all fields by default.
  143. : Object.keys(file.meta)
  144. metaFields.forEach((item) => {
  145. formPost.append(item, file.meta[item])
  146. })
  147. formPost.append(opts.fieldName, file.data)
  148. return formPost
  149. }
  150. createBareUpload (file, opts) {
  151. return file.data
  152. }
  153. upload (file, current, total) {
  154. const opts = this.getOptions(file)
  155. this.uppy.log(`uploading ${current} of ${total}`)
  156. return new Promise((resolve, reject) => {
  157. const data = opts.formData
  158. ? this.createFormDataUpload(file, opts)
  159. : this.createBareUpload(file, opts)
  160. const timer = this.createProgressTimeout(opts.timeout, (error) => {
  161. xhr.abort()
  162. this.uppy.emit('upload-error', file, error)
  163. reject(error)
  164. })
  165. const xhr = new XMLHttpRequest()
  166. const id = cuid()
  167. xhr.upload.addEventListener('loadstart', (ev) => {
  168. this.uppy.log(`[XHRUpload] ${id} started`)
  169. // Begin checking for timeouts when loading starts.
  170. timer.progress()
  171. })
  172. xhr.upload.addEventListener('progress', (ev) => {
  173. this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`)
  174. timer.progress()
  175. if (ev.lengthComputable) {
  176. this.uppy.emit('upload-progress', file, {
  177. uploader: this,
  178. bytesUploaded: ev.loaded,
  179. bytesTotal: ev.total
  180. })
  181. }
  182. })
  183. xhr.addEventListener('load', (ev) => {
  184. this.uppy.log(`[XHRUpload] ${id} finished`)
  185. timer.done()
  186. if (ev.target.status >= 200 && ev.target.status < 300) {
  187. const body = opts.getResponseData(xhr.responseText, xhr)
  188. const uploadURL = body[opts.responseUrlFieldName]
  189. const response = {
  190. status: ev.target.status,
  191. body,
  192. uploadURL
  193. }
  194. this.uppy.setFileState(file.id, { response })
  195. this.uppy.emit('upload-success', file, body, uploadURL)
  196. if (uploadURL) {
  197. this.uppy.log(`Download ${file.name} from ${file.uploadURL}`)
  198. }
  199. return resolve(file)
  200. } else {
  201. const body = opts.getResponseData(xhr.responseText, xhr)
  202. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  203. const response = {
  204. status: ev.target.status,
  205. body
  206. }
  207. this.uppy.setFileState(file.id, { response })
  208. this.uppy.emit('upload-error', file, error)
  209. return reject(error)
  210. }
  211. })
  212. xhr.addEventListener('error', (ev) => {
  213. this.uppy.log(`[XHRUpload] ${id} errored`)
  214. timer.done()
  215. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr))
  216. this.uppy.emit('upload-error', file, error)
  217. return reject(error)
  218. })
  219. xhr.open(opts.method.toUpperCase(), opts.endpoint, true)
  220. Object.keys(opts.headers).forEach((header) => {
  221. xhr.setRequestHeader(header, opts.headers[header])
  222. })
  223. xhr.send(data)
  224. this.uppy.on('file-removed', (removedFile) => {
  225. if (removedFile.id === file.id) {
  226. timer.done()
  227. xhr.abort()
  228. }
  229. })
  230. this.uppy.on('upload-cancel', (fileID) => {
  231. if (fileID === file.id) {
  232. timer.done()
  233. xhr.abort()
  234. }
  235. })
  236. this.uppy.on('cancel-all', () => {
  237. // const files = this.uppy.getState().files
  238. // if (!files[file.id]) return
  239. xhr.abort()
  240. })
  241. })
  242. }
  243. uploadRemote (file, current, total) {
  244. const opts = this.getOptions(file)
  245. return new Promise((resolve, reject) => {
  246. const fields = {}
  247. const metaFields = Array.isArray(opts.metaFields)
  248. ? opts.metaFields
  249. // Send along all fields by default.
  250. : Object.keys(file.meta)
  251. metaFields.forEach((name) => {
  252. fields[name] = file.meta[name]
  253. })
  254. fetch(file.remote.url, {
  255. method: 'post',
  256. credentials: 'include',
  257. headers: {
  258. 'Accept': 'application/json',
  259. 'Content-Type': 'application/json'
  260. },
  261. body: JSON.stringify(Object.assign({}, file.remote.body, {
  262. endpoint: opts.endpoint,
  263. size: file.data.size,
  264. fieldname: opts.fieldName,
  265. metadata: fields,
  266. headers: opts.headers
  267. }))
  268. })
  269. .then((res) => {
  270. if (res.status < 200 && res.status > 300) {
  271. return reject(res.statusText)
  272. }
  273. res.json().then((data) => {
  274. const token = data.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. }
  297. uploadBundle (files) {
  298. return new Promise((resolve, reject) => {
  299. const endpoint = this.opts.endpoint
  300. const method = this.opts.method
  301. const formData = new FormData()
  302. files.forEach((file, i) => {
  303. const opts = this.getOptions(file)
  304. formData.append(opts.fieldName, file.data)
  305. })
  306. const xhr = new XMLHttpRequest()
  307. const timer = this.createProgressTimeout(this.opts.timeout, (error) => {
  308. xhr.abort()
  309. emitError(error)
  310. reject(error)
  311. })
  312. const emitError = (error) => {
  313. files.forEach((file) => {
  314. this.uppy.emit('upload-error', file, error)
  315. })
  316. }
  317. xhr.upload.addEventListener('loadstart', (ev) => {
  318. this.uppy.log('[XHRUpload] started uploading bundle')
  319. timer.progress()
  320. })
  321. xhr.upload.addEventListener('progress', (ev) => {
  322. timer.progress()
  323. if (!ev.lengthComputable) return
  324. files.forEach((file) => {
  325. this.uppy.emit('upload-progress', file, {
  326. uploader: this,
  327. bytesUploaded: ev.loaded,
  328. bytesTotal: ev.total
  329. })
  330. })
  331. })
  332. xhr.addEventListener('load', (ev) => {
  333. timer.done()
  334. if (ev.target.status >= 200 && ev.target.status < 300) {
  335. const resp = this.opts.getResponseData(xhr.responseText, xhr)
  336. files.forEach((file) => {
  337. this.uppy.emit('upload-success', file, resp)
  338. })
  339. return resolve()
  340. }
  341. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  342. error.request = xhr
  343. emitError(error)
  344. return reject(error)
  345. })
  346. xhr.addEventListener('error', (ev) => {
  347. timer.done()
  348. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error')
  349. emitError(error)
  350. return reject(error)
  351. })
  352. this.uppy.on('cancel-all', () => {
  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. }