index.js 15 KB

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