index.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668
  1. "use strict";
  2. var _nonSecure = require("nanoid/non-secure");
  3. var _companionClient = require("@uppy/companion-client");
  4. var _RateLimitedQueue = require("@uppy/utils/lib/RateLimitedQueue");
  5. const BasePlugin = require("@uppy/core/lib/BasePlugin");
  6. const emitSocketProgress = require("@uppy/utils/lib/emitSocketProgress");
  7. const getSocketHost = require("@uppy/utils/lib/getSocketHost");
  8. const settle = require("@uppy/utils/lib/settle");
  9. const EventTracker = require("@uppy/utils/lib/EventTracker");
  10. const ProgressTimeout = require("@uppy/utils/lib/ProgressTimeout");
  11. const NetworkError = require("@uppy/utils/lib/NetworkError");
  12. const isNetworkError = require("@uppy/utils/lib/isNetworkError");
  13. const packageJson = {
  14. "version": "2.1.3"
  15. };
  16. const locale = require("./locale.js");
  17. function buildResponseError(xhr, err) {
  18. let error = err; // No error message
  19. if (!error) error = new Error('Upload error'); // Got an error message string
  20. if (typeof error === 'string') error = new Error(error); // Got something else
  21. if (!(error instanceof Error)) {
  22. error = Object.assign(new Error('Upload error'), {
  23. data: error
  24. });
  25. }
  26. if (isNetworkError(xhr)) {
  27. error = new NetworkError(error, xhr);
  28. return error;
  29. }
  30. error.request = xhr;
  31. return error;
  32. }
  33. /**
  34. * Set `data.type` in the blob to `file.meta.type`,
  35. * because we might have detected a more accurate file type in Uppy
  36. * https://stackoverflow.com/a/50875615
  37. *
  38. * @param {object} file File object with `data`, `size` and `meta` properties
  39. * @returns {object} blob updated with the new `type` set from `file.meta.type`
  40. */
  41. function setTypeInBlob(file) {
  42. const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type);
  43. return dataWithUpdatedType;
  44. }
  45. class XHRUpload extends BasePlugin {
  46. // eslint-disable-next-line global-require
  47. constructor(uppy, opts) {
  48. super(uppy, opts);
  49. this.type = 'uploader';
  50. this.id = this.opts.id || 'XHRUpload';
  51. this.title = 'XHRUpload';
  52. this.defaultLocale = locale; // Default options
  53. const defaultOptions = {
  54. formData: true,
  55. fieldName: opts.bundle ? 'files[]' : 'file',
  56. method: 'post',
  57. metaFields: null,
  58. responseUrlFieldName: 'url',
  59. bundle: false,
  60. headers: {},
  61. timeout: 30 * 1000,
  62. limit: 5,
  63. withCredentials: false,
  64. responseType: '',
  65. /**
  66. * @param {string} responseText the response body string
  67. */
  68. getResponseData(responseText) {
  69. let parsedResponse = {};
  70. try {
  71. parsedResponse = JSON.parse(responseText);
  72. } catch (err) {
  73. uppy.log(err);
  74. }
  75. return parsedResponse;
  76. },
  77. /**
  78. *
  79. * @param {string} _ the response body string
  80. * @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
  81. */
  82. getResponseError(_, response) {
  83. let error = new Error('Upload error');
  84. if (isNetworkError(response)) {
  85. error = new NetworkError(error, response);
  86. }
  87. return error;
  88. },
  89. /**
  90. * Check if the response from the upload endpoint indicates that the upload was successful.
  91. *
  92. * @param {number} status the response status code
  93. */
  94. validateStatus(status) {
  95. return status >= 200 && status < 300;
  96. }
  97. };
  98. this.opts = { ...defaultOptions,
  99. ...opts
  100. };
  101. this.i18nInit();
  102. this.handleUpload = this.handleUpload.bind(this); // Simultaneous upload limiting is shared across all uploads with this plugin.
  103. if (_RateLimitedQueue.internalRateLimitedQueue in this.opts) {
  104. this.requests = this.opts[_RateLimitedQueue.internalRateLimitedQueue];
  105. } else {
  106. this.requests = new _RateLimitedQueue.RateLimitedQueue(this.opts.limit);
  107. }
  108. if (this.opts.bundle && !this.opts.formData) {
  109. throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.');
  110. }
  111. this.uploaderEvents = Object.create(null);
  112. }
  113. getOptions(file) {
  114. const overrides = this.uppy.getState().xhrUpload;
  115. const {
  116. headers
  117. } = this.opts;
  118. const opts = { ...this.opts,
  119. ...(overrides || {}),
  120. ...(file.xhrUpload || {}),
  121. headers: {}
  122. }; // Support for `headers` as a function, only in the XHRUpload settings.
  123. // Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
  124. //
  125. // ```js
  126. // headers: (file) => ({ expires: file.meta.expires })
  127. // ```
  128. if (typeof headers === 'function') {
  129. opts.headers = headers(file);
  130. } else {
  131. Object.assign(opts.headers, this.opts.headers);
  132. }
  133. if (overrides) {
  134. Object.assign(opts.headers, overrides.headers);
  135. }
  136. if (file.xhrUpload) {
  137. Object.assign(opts.headers, file.xhrUpload.headers);
  138. }
  139. return opts;
  140. } // eslint-disable-next-line class-methods-use-this
  141. addMetadata(formData, meta, opts) {
  142. const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields : Object.keys(meta); // Send along all fields by default.
  143. metaFields.forEach(item => {
  144. formData.append(item, meta[item]);
  145. });
  146. }
  147. createFormDataUpload(file, opts) {
  148. const formPost = new FormData();
  149. this.addMetadata(formPost, file.meta, opts);
  150. const dataWithUpdatedType = setTypeInBlob(file);
  151. if (file.name) {
  152. formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name);
  153. } else {
  154. formPost.append(opts.fieldName, dataWithUpdatedType);
  155. }
  156. return formPost;
  157. }
  158. createBundledUpload(files, opts) {
  159. const formPost = new FormData();
  160. const {
  161. meta
  162. } = this.uppy.getState();
  163. this.addMetadata(formPost, meta, opts);
  164. files.forEach(file => {
  165. const options = this.getOptions(file);
  166. const dataWithUpdatedType = setTypeInBlob(file);
  167. if (file.name) {
  168. formPost.append(options.fieldName, dataWithUpdatedType, file.name);
  169. } else {
  170. formPost.append(options.fieldName, dataWithUpdatedType);
  171. }
  172. });
  173. return formPost;
  174. }
  175. upload(file, current, total) {
  176. const opts = this.getOptions(file);
  177. this.uppy.log(`uploading ${current} of ${total}`);
  178. return new Promise((resolve, reject) => {
  179. this.uppy.emit('upload-started', file);
  180. const data = opts.formData ? this.createFormDataUpload(file, opts) : file.data;
  181. const xhr = new XMLHttpRequest();
  182. this.uploaderEvents[file.id] = new EventTracker(this.uppy);
  183. let queuedRequest;
  184. const timer = new ProgressTimeout(opts.timeout, () => {
  185. xhr.abort();
  186. queuedRequest.done();
  187. const error = new Error(this.i18n('timedOut', {
  188. seconds: Math.ceil(opts.timeout / 1000)
  189. }));
  190. this.uppy.emit('upload-error', file, error);
  191. reject(error);
  192. });
  193. const id = (0, _nonSecure.nanoid)();
  194. xhr.upload.addEventListener('loadstart', () => {
  195. this.uppy.log(`[XHRUpload] ${id} started`);
  196. });
  197. xhr.upload.addEventListener('progress', ev => {
  198. this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`); // Begin checking for timeouts when progress starts, instead of loading,
  199. // to avoid timing out requests on browser concurrency queue
  200. timer.progress();
  201. if (ev.lengthComputable) {
  202. this.uppy.emit('upload-progress', file, {
  203. uploader: this,
  204. bytesUploaded: ev.loaded,
  205. bytesTotal: ev.total
  206. });
  207. }
  208. });
  209. xhr.addEventListener('load', () => {
  210. this.uppy.log(`[XHRUpload] ${id} finished`);
  211. timer.done();
  212. queuedRequest.done();
  213. if (this.uploaderEvents[file.id]) {
  214. this.uploaderEvents[file.id].remove();
  215. this.uploaderEvents[file.id] = null;
  216. }
  217. if (opts.validateStatus(xhr.status, xhr.responseText, xhr)) {
  218. const body = opts.getResponseData(xhr.responseText, xhr);
  219. const uploadURL = body[opts.responseUrlFieldName];
  220. const uploadResp = {
  221. status: xhr.status,
  222. body,
  223. uploadURL
  224. };
  225. this.uppy.emit('upload-success', file, uploadResp);
  226. if (uploadURL) {
  227. this.uppy.log(`Download ${file.name} from ${uploadURL}`);
  228. }
  229. return resolve(file);
  230. }
  231. const body = opts.getResponseData(xhr.responseText, xhr);
  232. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr));
  233. const response = {
  234. status: xhr.status,
  235. body
  236. };
  237. this.uppy.emit('upload-error', file, error, response);
  238. return reject(error);
  239. });
  240. xhr.addEventListener('error', () => {
  241. this.uppy.log(`[XHRUpload] ${id} errored`);
  242. timer.done();
  243. queuedRequest.done();
  244. if (this.uploaderEvents[file.id]) {
  245. this.uploaderEvents[file.id].remove();
  246. this.uploaderEvents[file.id] = null;
  247. }
  248. const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr));
  249. this.uppy.emit('upload-error', file, error);
  250. return reject(error);
  251. });
  252. xhr.open(opts.method.toUpperCase(), opts.endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType`
  253. // before `open()` is called.
  254. xhr.withCredentials = opts.withCredentials;
  255. if (opts.responseType !== '') {
  256. xhr.responseType = opts.responseType;
  257. }
  258. queuedRequest = this.requests.run(() => {
  259. this.uppy.emit('upload-started', file); // When using an authentication system like JWT, the bearer token goes as a header. This
  260. // header needs to be fresh each time the token is refreshed so computing and setting the
  261. // headers just before the upload starts enables this kind of authentication to work properly.
  262. // Otherwise, half-way through the list of uploads the token could be stale and the upload would fail.
  263. const currentOpts = this.getOptions(file);
  264. Object.keys(currentOpts.headers).forEach(header => {
  265. xhr.setRequestHeader(header, currentOpts.headers[header]);
  266. });
  267. xhr.send(data);
  268. return () => {
  269. timer.done();
  270. xhr.abort();
  271. };
  272. });
  273. this.onFileRemove(file.id, () => {
  274. queuedRequest.abort();
  275. reject(new Error('File removed'));
  276. });
  277. this.onCancelAll(file.id, _ref => {
  278. let {
  279. reason
  280. } = _ref;
  281. if (reason === 'user') {
  282. queuedRequest.abort();
  283. }
  284. reject(new Error('Upload cancelled'));
  285. });
  286. });
  287. }
  288. uploadRemote(file) {
  289. const opts = this.getOptions(file);
  290. return new Promise((resolve, reject) => {
  291. this.uppy.emit('upload-started', file);
  292. const fields = {};
  293. const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields // Send along all fields by default.
  294. : Object.keys(file.meta);
  295. metaFields.forEach(name => {
  296. fields[name] = file.meta[name];
  297. });
  298. const Client = file.remote.providerOptions.provider ? _companionClient.Provider : _companionClient.RequestClient;
  299. const client = new Client(this.uppy, file.remote.providerOptions);
  300. client.post(file.remote.url, { ...file.remote.body,
  301. endpoint: opts.endpoint,
  302. size: file.data.size,
  303. fieldname: opts.fieldName,
  304. metadata: fields,
  305. httpMethod: opts.method,
  306. useFormData: opts.formData,
  307. headers: opts.headers
  308. }).then(res => {
  309. const {
  310. token
  311. } = res;
  312. const host = getSocketHost(file.remote.companionUrl);
  313. const socket = new _companionClient.Socket({
  314. target: `${host}/api/${token}`,
  315. autoOpen: false
  316. });
  317. this.uploaderEvents[file.id] = new EventTracker(this.uppy);
  318. let queuedRequest;
  319. this.onFileRemove(file.id, () => {
  320. socket.send('cancel', {});
  321. queuedRequest.abort();
  322. resolve(`upload ${file.id} was removed`);
  323. });
  324. this.onCancelAll(file.id, function (_temp) {
  325. let {
  326. reason
  327. } = _temp === void 0 ? {} : _temp;
  328. if (reason === 'user') {
  329. socket.send('cancel', {});
  330. queuedRequest.abort();
  331. }
  332. resolve(`upload ${file.id} was canceled`);
  333. });
  334. this.onRetry(file.id, () => {
  335. socket.send('pause', {});
  336. socket.send('resume', {});
  337. });
  338. this.onRetryAll(file.id, () => {
  339. socket.send('pause', {});
  340. socket.send('resume', {});
  341. });
  342. socket.on('progress', progressData => emitSocketProgress(this, progressData, file));
  343. socket.on('success', data => {
  344. const body = opts.getResponseData(data.response.responseText, data.response);
  345. const uploadURL = body[opts.responseUrlFieldName];
  346. const uploadResp = {
  347. status: data.response.status,
  348. body,
  349. uploadURL
  350. };
  351. this.uppy.emit('upload-success', file, uploadResp);
  352. queuedRequest.done();
  353. if (this.uploaderEvents[file.id]) {
  354. this.uploaderEvents[file.id].remove();
  355. this.uploaderEvents[file.id] = null;
  356. }
  357. return resolve();
  358. });
  359. socket.on('error', errData => {
  360. const resp = errData.response;
  361. const error = resp ? opts.getResponseError(resp.responseText, resp) : Object.assign(new Error(errData.error.message), {
  362. cause: errData.error
  363. });
  364. this.uppy.emit('upload-error', file, error);
  365. queuedRequest.done();
  366. if (this.uploaderEvents[file.id]) {
  367. this.uploaderEvents[file.id].remove();
  368. this.uploaderEvents[file.id] = null;
  369. }
  370. reject(error);
  371. });
  372. queuedRequest = this.requests.run(() => {
  373. socket.open();
  374. if (file.isPaused) {
  375. socket.send('pause', {});
  376. }
  377. return () => socket.close();
  378. });
  379. }).catch(err => {
  380. this.uppy.emit('upload-error', file, err);
  381. reject(err);
  382. });
  383. });
  384. }
  385. uploadBundle(files) {
  386. return new Promise((resolve, reject) => {
  387. const {
  388. endpoint
  389. } = this.opts;
  390. const {
  391. method
  392. } = this.opts;
  393. const optsFromState = this.uppy.getState().xhrUpload;
  394. const formData = this.createBundledUpload(files, { ...this.opts,
  395. ...(optsFromState || {})
  396. });
  397. const xhr = new XMLHttpRequest();
  398. const emitError = error => {
  399. files.forEach(file => {
  400. this.uppy.emit('upload-error', file, error);
  401. });
  402. };
  403. const timer = new ProgressTimeout(this.opts.timeout, () => {
  404. xhr.abort();
  405. const error = new Error(this.i18n('timedOut', {
  406. seconds: Math.ceil(this.opts.timeout / 1000)
  407. }));
  408. emitError(error);
  409. reject(error);
  410. });
  411. xhr.upload.addEventListener('loadstart', () => {
  412. this.uppy.log('[XHRUpload] started uploading bundle');
  413. timer.progress();
  414. });
  415. xhr.upload.addEventListener('progress', ev => {
  416. timer.progress();
  417. if (!ev.lengthComputable) return;
  418. files.forEach(file => {
  419. this.uppy.emit('upload-progress', file, {
  420. uploader: this,
  421. bytesUploaded: ev.loaded / ev.total * file.size,
  422. bytesTotal: file.size
  423. });
  424. });
  425. });
  426. xhr.addEventListener('load', ev => {
  427. timer.done();
  428. if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
  429. const body = this.opts.getResponseData(xhr.responseText, xhr);
  430. const uploadResp = {
  431. status: ev.target.status,
  432. body
  433. };
  434. files.forEach(file => {
  435. this.uppy.emit('upload-success', file, uploadResp);
  436. });
  437. return resolve();
  438. }
  439. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error');
  440. error.request = xhr;
  441. emitError(error);
  442. return reject(error);
  443. });
  444. xhr.addEventListener('error', () => {
  445. timer.done();
  446. const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error');
  447. emitError(error);
  448. return reject(error);
  449. });
  450. this.uppy.on('cancel-all', function (_temp2) {
  451. let {
  452. reason
  453. } = _temp2 === void 0 ? {} : _temp2;
  454. if (reason !== 'user') return;
  455. timer.done();
  456. xhr.abort();
  457. });
  458. xhr.open(method.toUpperCase(), endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType`
  459. // before `open()` is called.
  460. xhr.withCredentials = this.opts.withCredentials;
  461. if (this.opts.responseType !== '') {
  462. xhr.responseType = this.opts.responseType;
  463. }
  464. Object.keys(this.opts.headers).forEach(header => {
  465. xhr.setRequestHeader(header, this.opts.headers[header]);
  466. });
  467. xhr.send(formData);
  468. files.forEach(file => {
  469. this.uppy.emit('upload-started', file);
  470. });
  471. });
  472. }
  473. uploadFiles(files) {
  474. const promises = files.map((file, i) => {
  475. const current = parseInt(i, 10) + 1;
  476. const total = files.length;
  477. if (file.error) {
  478. return Promise.reject(new Error(file.error));
  479. }
  480. if (file.isRemote) {
  481. return this.uploadRemote(file, current, total);
  482. }
  483. return this.upload(file, current, total);
  484. });
  485. return settle(promises);
  486. }
  487. onFileRemove(fileID, cb) {
  488. this.uploaderEvents[fileID].on('file-removed', file => {
  489. if (fileID === file.id) cb(file.id);
  490. });
  491. }
  492. onRetry(fileID, cb) {
  493. this.uploaderEvents[fileID].on('upload-retry', targetFileID => {
  494. if (fileID === targetFileID) {
  495. cb();
  496. }
  497. });
  498. }
  499. onRetryAll(fileID, cb) {
  500. this.uploaderEvents[fileID].on('retry-all', () => {
  501. if (!this.uppy.getFile(fileID)) return;
  502. cb();
  503. });
  504. }
  505. onCancelAll(fileID, eventHandler) {
  506. var _this = this;
  507. this.uploaderEvents[fileID].on('cancel-all', function () {
  508. if (!_this.uppy.getFile(fileID)) return;
  509. eventHandler(...arguments);
  510. });
  511. }
  512. handleUpload(fileIDs) {
  513. if (fileIDs.length === 0) {
  514. this.uppy.log('[XHRUpload] No files to upload!');
  515. return Promise.resolve();
  516. } // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin
  517. // (basically just AwsS3) using the internal symbol
  518. if (this.opts.limit === 0 && !this.opts[_RateLimitedQueue.internalRateLimitedQueue]) {
  519. this.uppy.log('[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0', 'warning');
  520. }
  521. this.uppy.log('[XHRUpload] Uploading...');
  522. const files = fileIDs.map(fileID => this.uppy.getFile(fileID));
  523. if (this.opts.bundle) {
  524. // if bundle: true, we don’t support remote uploads
  525. const isSomeFileRemote = files.some(file => file.isRemote);
  526. if (isSomeFileRemote) {
  527. throw new Error('Can’t upload remote files when the `bundle: true` option is set');
  528. }
  529. if (typeof this.opts.headers === 'function') {
  530. throw new TypeError('`headers` may not be a function when the `bundle: true` option is set');
  531. }
  532. return this.uploadBundle(files);
  533. }
  534. return this.uploadFiles(files).then(() => null);
  535. }
  536. install() {
  537. if (this.opts.bundle) {
  538. const {
  539. capabilities
  540. } = this.uppy.getState();
  541. this.uppy.setState({
  542. capabilities: { ...capabilities,
  543. individualCancellation: false
  544. }
  545. });
  546. }
  547. this.uppy.addUploader(this.handleUpload);
  548. }
  549. uninstall() {
  550. if (this.opts.bundle) {
  551. const {
  552. capabilities
  553. } = this.uppy.getState();
  554. this.uppy.setState({
  555. capabilities: { ...capabilities,
  556. individualCancellation: true
  557. }
  558. });
  559. }
  560. this.uppy.removeUploader(this.handleUpload);
  561. }
  562. }
  563. XHRUpload.VERSION = packageJson.version;
  564. module.exports = XHRUpload;