index.js 15 KB

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