index.js 14 KB

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