668 lines
20 KiB
JavaScript
668 lines
20 KiB
JavaScript
"use strict";
|
||
|
||
var _nonSecure = require("nanoid/non-secure");
|
||
|
||
var _companionClient = require("@uppy/companion-client");
|
||
|
||
var _RateLimitedQueue = require("@uppy/utils/lib/RateLimitedQueue");
|
||
|
||
const BasePlugin = require("@uppy/core/lib/BasePlugin");
|
||
|
||
const emitSocketProgress = require("@uppy/utils/lib/emitSocketProgress");
|
||
|
||
const getSocketHost = require("@uppy/utils/lib/getSocketHost");
|
||
|
||
const settle = require("@uppy/utils/lib/settle");
|
||
|
||
const EventTracker = require("@uppy/utils/lib/EventTracker");
|
||
|
||
const ProgressTimeout = require("@uppy/utils/lib/ProgressTimeout");
|
||
|
||
const NetworkError = require("@uppy/utils/lib/NetworkError");
|
||
|
||
const isNetworkError = require("@uppy/utils/lib/isNetworkError");
|
||
|
||
const packageJson = {
|
||
"version": "2.1.3"
|
||
};
|
||
|
||
const locale = require("./locale.js");
|
||
|
||
function buildResponseError(xhr, err) {
|
||
let error = err; // No error message
|
||
|
||
if (!error) error = new Error('Upload error'); // Got an error message string
|
||
|
||
if (typeof error === 'string') error = new Error(error); // Got something else
|
||
|
||
if (!(error instanceof Error)) {
|
||
error = Object.assign(new Error('Upload error'), {
|
||
data: error
|
||
});
|
||
}
|
||
|
||
if (isNetworkError(xhr)) {
|
||
error = new NetworkError(error, xhr);
|
||
return error;
|
||
}
|
||
|
||
error.request = xhr;
|
||
return error;
|
||
}
|
||
/**
|
||
* Set `data.type` in the blob to `file.meta.type`,
|
||
* because we might have detected a more accurate file type in Uppy
|
||
* https://stackoverflow.com/a/50875615
|
||
*
|
||
* @param {object} file File object with `data`, `size` and `meta` properties
|
||
* @returns {object} blob updated with the new `type` set from `file.meta.type`
|
||
*/
|
||
|
||
|
||
function setTypeInBlob(file) {
|
||
const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type);
|
||
return dataWithUpdatedType;
|
||
}
|
||
|
||
class XHRUpload extends BasePlugin {
|
||
// eslint-disable-next-line global-require
|
||
constructor(uppy, opts) {
|
||
super(uppy, opts);
|
||
this.type = 'uploader';
|
||
this.id = this.opts.id || 'XHRUpload';
|
||
this.title = 'XHRUpload';
|
||
this.defaultLocale = locale; // Default options
|
||
|
||
const defaultOptions = {
|
||
formData: true,
|
||
fieldName: opts.bundle ? 'files[]' : 'file',
|
||
method: 'post',
|
||
metaFields: null,
|
||
responseUrlFieldName: 'url',
|
||
bundle: false,
|
||
headers: {},
|
||
timeout: 30 * 1000,
|
||
limit: 5,
|
||
withCredentials: false,
|
||
responseType: '',
|
||
|
||
/**
|
||
* @param {string} responseText the response body string
|
||
*/
|
||
getResponseData(responseText) {
|
||
let parsedResponse = {};
|
||
|
||
try {
|
||
parsedResponse = JSON.parse(responseText);
|
||
} catch (err) {
|
||
uppy.log(err);
|
||
}
|
||
|
||
return parsedResponse;
|
||
},
|
||
|
||
/**
|
||
*
|
||
* @param {string} _ the response body string
|
||
* @param {XMLHttpRequest | respObj} response the response object (XHR or similar)
|
||
*/
|
||
getResponseError(_, response) {
|
||
let error = new Error('Upload error');
|
||
|
||
if (isNetworkError(response)) {
|
||
error = new NetworkError(error, response);
|
||
}
|
||
|
||
return error;
|
||
},
|
||
|
||
/**
|
||
* Check if the response from the upload endpoint indicates that the upload was successful.
|
||
*
|
||
* @param {number} status the response status code
|
||
*/
|
||
validateStatus(status) {
|
||
return status >= 200 && status < 300;
|
||
}
|
||
|
||
};
|
||
this.opts = { ...defaultOptions,
|
||
...opts
|
||
};
|
||
this.i18nInit();
|
||
this.handleUpload = this.handleUpload.bind(this); // Simultaneous upload limiting is shared across all uploads with this plugin.
|
||
|
||
if (_RateLimitedQueue.internalRateLimitedQueue in this.opts) {
|
||
this.requests = this.opts[_RateLimitedQueue.internalRateLimitedQueue];
|
||
} else {
|
||
this.requests = new _RateLimitedQueue.RateLimitedQueue(this.opts.limit);
|
||
}
|
||
|
||
if (this.opts.bundle && !this.opts.formData) {
|
||
throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.');
|
||
}
|
||
|
||
this.uploaderEvents = Object.create(null);
|
||
}
|
||
|
||
getOptions(file) {
|
||
const overrides = this.uppy.getState().xhrUpload;
|
||
const {
|
||
headers
|
||
} = this.opts;
|
||
const opts = { ...this.opts,
|
||
...(overrides || {}),
|
||
...(file.xhrUpload || {}),
|
||
headers: {}
|
||
}; // Support for `headers` as a function, only in the XHRUpload settings.
|
||
// Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
|
||
//
|
||
// ```js
|
||
// headers: (file) => ({ expires: file.meta.expires })
|
||
// ```
|
||
|
||
if (typeof headers === 'function') {
|
||
opts.headers = headers(file);
|
||
} else {
|
||
Object.assign(opts.headers, this.opts.headers);
|
||
}
|
||
|
||
if (overrides) {
|
||
Object.assign(opts.headers, overrides.headers);
|
||
}
|
||
|
||
if (file.xhrUpload) {
|
||
Object.assign(opts.headers, file.xhrUpload.headers);
|
||
}
|
||
|
||
return opts;
|
||
} // eslint-disable-next-line class-methods-use-this
|
||
|
||
|
||
addMetadata(formData, meta, opts) {
|
||
const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields : Object.keys(meta); // Send along all fields by default.
|
||
|
||
metaFields.forEach(item => {
|
||
formData.append(item, meta[item]);
|
||
});
|
||
}
|
||
|
||
createFormDataUpload(file, opts) {
|
||
const formPost = new FormData();
|
||
this.addMetadata(formPost, file.meta, opts);
|
||
const dataWithUpdatedType = setTypeInBlob(file);
|
||
|
||
if (file.name) {
|
||
formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name);
|
||
} else {
|
||
formPost.append(opts.fieldName, dataWithUpdatedType);
|
||
}
|
||
|
||
return formPost;
|
||
}
|
||
|
||
createBundledUpload(files, opts) {
|
||
const formPost = new FormData();
|
||
const {
|
||
meta
|
||
} = this.uppy.getState();
|
||
this.addMetadata(formPost, meta, opts);
|
||
files.forEach(file => {
|
||
const options = this.getOptions(file);
|
||
const dataWithUpdatedType = setTypeInBlob(file);
|
||
|
||
if (file.name) {
|
||
formPost.append(options.fieldName, dataWithUpdatedType, file.name);
|
||
} else {
|
||
formPost.append(options.fieldName, dataWithUpdatedType);
|
||
}
|
||
});
|
||
return formPost;
|
||
}
|
||
|
||
upload(file, current, total) {
|
||
const opts = this.getOptions(file);
|
||
this.uppy.log(`uploading ${current} of ${total}`);
|
||
return new Promise((resolve, reject) => {
|
||
this.uppy.emit('upload-started', file);
|
||
const data = opts.formData ? this.createFormDataUpload(file, opts) : file.data;
|
||
const xhr = new XMLHttpRequest();
|
||
this.uploaderEvents[file.id] = new EventTracker(this.uppy);
|
||
let queuedRequest;
|
||
const timer = new ProgressTimeout(opts.timeout, () => {
|
||
xhr.abort();
|
||
queuedRequest.done();
|
||
const error = new Error(this.i18n('timedOut', {
|
||
seconds: Math.ceil(opts.timeout / 1000)
|
||
}));
|
||
this.uppy.emit('upload-error', file, error);
|
||
reject(error);
|
||
});
|
||
const id = (0, _nonSecure.nanoid)();
|
||
xhr.upload.addEventListener('loadstart', () => {
|
||
this.uppy.log(`[XHRUpload] ${id} started`);
|
||
});
|
||
xhr.upload.addEventListener('progress', ev => {
|
||
this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`); // Begin checking for timeouts when progress starts, instead of loading,
|
||
// to avoid timing out requests on browser concurrency queue
|
||
|
||
timer.progress();
|
||
|
||
if (ev.lengthComputable) {
|
||
this.uppy.emit('upload-progress', file, {
|
||
uploader: this,
|
||
bytesUploaded: ev.loaded,
|
||
bytesTotal: ev.total
|
||
});
|
||
}
|
||
});
|
||
xhr.addEventListener('load', () => {
|
||
this.uppy.log(`[XHRUpload] ${id} finished`);
|
||
timer.done();
|
||
queuedRequest.done();
|
||
|
||
if (this.uploaderEvents[file.id]) {
|
||
this.uploaderEvents[file.id].remove();
|
||
this.uploaderEvents[file.id] = null;
|
||
}
|
||
|
||
if (opts.validateStatus(xhr.status, xhr.responseText, xhr)) {
|
||
const body = opts.getResponseData(xhr.responseText, xhr);
|
||
const uploadURL = body[opts.responseUrlFieldName];
|
||
const uploadResp = {
|
||
status: xhr.status,
|
||
body,
|
||
uploadURL
|
||
};
|
||
this.uppy.emit('upload-success', file, uploadResp);
|
||
|
||
if (uploadURL) {
|
||
this.uppy.log(`Download ${file.name} from ${uploadURL}`);
|
||
}
|
||
|
||
return resolve(file);
|
||
}
|
||
|
||
const body = opts.getResponseData(xhr.responseText, xhr);
|
||
const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr));
|
||
const response = {
|
||
status: xhr.status,
|
||
body
|
||
};
|
||
this.uppy.emit('upload-error', file, error, response);
|
||
return reject(error);
|
||
});
|
||
xhr.addEventListener('error', () => {
|
||
this.uppy.log(`[XHRUpload] ${id} errored`);
|
||
timer.done();
|
||
queuedRequest.done();
|
||
|
||
if (this.uploaderEvents[file.id]) {
|
||
this.uploaderEvents[file.id].remove();
|
||
this.uploaderEvents[file.id] = null;
|
||
}
|
||
|
||
const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr));
|
||
this.uppy.emit('upload-error', file, error);
|
||
return reject(error);
|
||
});
|
||
xhr.open(opts.method.toUpperCase(), opts.endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType`
|
||
// before `open()` is called.
|
||
|
||
xhr.withCredentials = opts.withCredentials;
|
||
|
||
if (opts.responseType !== '') {
|
||
xhr.responseType = opts.responseType;
|
||
}
|
||
|
||
queuedRequest = this.requests.run(() => {
|
||
this.uppy.emit('upload-started', file); // When using an authentication system like JWT, the bearer token goes as a header. This
|
||
// header needs to be fresh each time the token is refreshed so computing and setting the
|
||
// headers just before the upload starts enables this kind of authentication to work properly.
|
||
// Otherwise, half-way through the list of uploads the token could be stale and the upload would fail.
|
||
|
||
const currentOpts = this.getOptions(file);
|
||
Object.keys(currentOpts.headers).forEach(header => {
|
||
xhr.setRequestHeader(header, currentOpts.headers[header]);
|
||
});
|
||
xhr.send(data);
|
||
return () => {
|
||
timer.done();
|
||
xhr.abort();
|
||
};
|
||
});
|
||
this.onFileRemove(file.id, () => {
|
||
queuedRequest.abort();
|
||
reject(new Error('File removed'));
|
||
});
|
||
this.onCancelAll(file.id, _ref => {
|
||
let {
|
||
reason
|
||
} = _ref;
|
||
|
||
if (reason === 'user') {
|
||
queuedRequest.abort();
|
||
}
|
||
|
||
reject(new Error('Upload cancelled'));
|
||
});
|
||
});
|
||
}
|
||
|
||
uploadRemote(file) {
|
||
const opts = this.getOptions(file);
|
||
return new Promise((resolve, reject) => {
|
||
this.uppy.emit('upload-started', file);
|
||
const fields = {};
|
||
const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields // Send along all fields by default.
|
||
: Object.keys(file.meta);
|
||
metaFields.forEach(name => {
|
||
fields[name] = file.meta[name];
|
||
});
|
||
const Client = file.remote.providerOptions.provider ? _companionClient.Provider : _companionClient.RequestClient;
|
||
const client = new Client(this.uppy, file.remote.providerOptions);
|
||
client.post(file.remote.url, { ...file.remote.body,
|
||
endpoint: opts.endpoint,
|
||
size: file.data.size,
|
||
fieldname: opts.fieldName,
|
||
metadata: fields,
|
||
httpMethod: opts.method,
|
||
useFormData: opts.formData,
|
||
headers: opts.headers
|
||
}).then(res => {
|
||
const {
|
||
token
|
||
} = res;
|
||
const host = getSocketHost(file.remote.companionUrl);
|
||
const socket = new _companionClient.Socket({
|
||
target: `${host}/api/${token}`,
|
||
autoOpen: false
|
||
});
|
||
this.uploaderEvents[file.id] = new EventTracker(this.uppy);
|
||
let queuedRequest;
|
||
this.onFileRemove(file.id, () => {
|
||
socket.send('cancel', {});
|
||
queuedRequest.abort();
|
||
resolve(`upload ${file.id} was removed`);
|
||
});
|
||
this.onCancelAll(file.id, function (_temp) {
|
||
let {
|
||
reason
|
||
} = _temp === void 0 ? {} : _temp;
|
||
|
||
if (reason === 'user') {
|
||
socket.send('cancel', {});
|
||
queuedRequest.abort();
|
||
}
|
||
|
||
resolve(`upload ${file.id} was canceled`);
|
||
});
|
||
this.onRetry(file.id, () => {
|
||
socket.send('pause', {});
|
||
socket.send('resume', {});
|
||
});
|
||
this.onRetryAll(file.id, () => {
|
||
socket.send('pause', {});
|
||
socket.send('resume', {});
|
||
});
|
||
socket.on('progress', progressData => emitSocketProgress(this, progressData, file));
|
||
socket.on('success', data => {
|
||
const body = opts.getResponseData(data.response.responseText, data.response);
|
||
const uploadURL = body[opts.responseUrlFieldName];
|
||
const uploadResp = {
|
||
status: data.response.status,
|
||
body,
|
||
uploadURL
|
||
};
|
||
this.uppy.emit('upload-success', file, uploadResp);
|
||
queuedRequest.done();
|
||
|
||
if (this.uploaderEvents[file.id]) {
|
||
this.uploaderEvents[file.id].remove();
|
||
this.uploaderEvents[file.id] = null;
|
||
}
|
||
|
||
return resolve();
|
||
});
|
||
socket.on('error', errData => {
|
||
const resp = errData.response;
|
||
const error = resp ? opts.getResponseError(resp.responseText, resp) : Object.assign(new Error(errData.error.message), {
|
||
cause: errData.error
|
||
});
|
||
this.uppy.emit('upload-error', file, error);
|
||
queuedRequest.done();
|
||
|
||
if (this.uploaderEvents[file.id]) {
|
||
this.uploaderEvents[file.id].remove();
|
||
this.uploaderEvents[file.id] = null;
|
||
}
|
||
|
||
reject(error);
|
||
});
|
||
queuedRequest = this.requests.run(() => {
|
||
socket.open();
|
||
|
||
if (file.isPaused) {
|
||
socket.send('pause', {});
|
||
}
|
||
|
||
return () => socket.close();
|
||
});
|
||
}).catch(err => {
|
||
this.uppy.emit('upload-error', file, err);
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
uploadBundle(files) {
|
||
return new Promise((resolve, reject) => {
|
||
const {
|
||
endpoint
|
||
} = this.opts;
|
||
const {
|
||
method
|
||
} = this.opts;
|
||
const optsFromState = this.uppy.getState().xhrUpload;
|
||
const formData = this.createBundledUpload(files, { ...this.opts,
|
||
...(optsFromState || {})
|
||
});
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
const emitError = error => {
|
||
files.forEach(file => {
|
||
this.uppy.emit('upload-error', file, error);
|
||
});
|
||
};
|
||
|
||
const timer = new ProgressTimeout(this.opts.timeout, () => {
|
||
xhr.abort();
|
||
const error = new Error(this.i18n('timedOut', {
|
||
seconds: Math.ceil(this.opts.timeout / 1000)
|
||
}));
|
||
emitError(error);
|
||
reject(error);
|
||
});
|
||
xhr.upload.addEventListener('loadstart', () => {
|
||
this.uppy.log('[XHRUpload] started uploading bundle');
|
||
timer.progress();
|
||
});
|
||
xhr.upload.addEventListener('progress', ev => {
|
||
timer.progress();
|
||
if (!ev.lengthComputable) return;
|
||
files.forEach(file => {
|
||
this.uppy.emit('upload-progress', file, {
|
||
uploader: this,
|
||
bytesUploaded: ev.loaded / ev.total * file.size,
|
||
bytesTotal: file.size
|
||
});
|
||
});
|
||
});
|
||
xhr.addEventListener('load', ev => {
|
||
timer.done();
|
||
|
||
if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) {
|
||
const body = this.opts.getResponseData(xhr.responseText, xhr);
|
||
const uploadResp = {
|
||
status: ev.target.status,
|
||
body
|
||
};
|
||
files.forEach(file => {
|
||
this.uppy.emit('upload-success', file, uploadResp);
|
||
});
|
||
return resolve();
|
||
}
|
||
|
||
const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error');
|
||
error.request = xhr;
|
||
emitError(error);
|
||
return reject(error);
|
||
});
|
||
xhr.addEventListener('error', () => {
|
||
timer.done();
|
||
const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error');
|
||
emitError(error);
|
||
return reject(error);
|
||
});
|
||
this.uppy.on('cancel-all', function (_temp2) {
|
||
let {
|
||
reason
|
||
} = _temp2 === void 0 ? {} : _temp2;
|
||
if (reason !== 'user') return;
|
||
timer.done();
|
||
xhr.abort();
|
||
});
|
||
xhr.open(method.toUpperCase(), endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType`
|
||
// before `open()` is called.
|
||
|
||
xhr.withCredentials = this.opts.withCredentials;
|
||
|
||
if (this.opts.responseType !== '') {
|
||
xhr.responseType = this.opts.responseType;
|
||
}
|
||
|
||
Object.keys(this.opts.headers).forEach(header => {
|
||
xhr.setRequestHeader(header, this.opts.headers[header]);
|
||
});
|
||
xhr.send(formData);
|
||
files.forEach(file => {
|
||
this.uppy.emit('upload-started', file);
|
||
});
|
||
});
|
||
}
|
||
|
||
uploadFiles(files) {
|
||
const promises = files.map((file, i) => {
|
||
const current = parseInt(i, 10) + 1;
|
||
const total = files.length;
|
||
|
||
if (file.error) {
|
||
return Promise.reject(new Error(file.error));
|
||
}
|
||
|
||
if (file.isRemote) {
|
||
return this.uploadRemote(file, current, total);
|
||
}
|
||
|
||
return this.upload(file, current, total);
|
||
});
|
||
return settle(promises);
|
||
}
|
||
|
||
onFileRemove(fileID, cb) {
|
||
this.uploaderEvents[fileID].on('file-removed', file => {
|
||
if (fileID === file.id) cb(file.id);
|
||
});
|
||
}
|
||
|
||
onRetry(fileID, cb) {
|
||
this.uploaderEvents[fileID].on('upload-retry', targetFileID => {
|
||
if (fileID === targetFileID) {
|
||
cb();
|
||
}
|
||
});
|
||
}
|
||
|
||
onRetryAll(fileID, cb) {
|
||
this.uploaderEvents[fileID].on('retry-all', () => {
|
||
if (!this.uppy.getFile(fileID)) return;
|
||
cb();
|
||
});
|
||
}
|
||
|
||
onCancelAll(fileID, eventHandler) {
|
||
var _this = this;
|
||
|
||
this.uploaderEvents[fileID].on('cancel-all', function () {
|
||
if (!_this.uppy.getFile(fileID)) return;
|
||
eventHandler(...arguments);
|
||
});
|
||
}
|
||
|
||
handleUpload(fileIDs) {
|
||
if (fileIDs.length === 0) {
|
||
this.uppy.log('[XHRUpload] No files to upload!');
|
||
return Promise.resolve();
|
||
} // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin
|
||
// (basically just AwsS3) using the internal symbol
|
||
|
||
|
||
if (this.opts.limit === 0 && !this.opts[_RateLimitedQueue.internalRateLimitedQueue]) {
|
||
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');
|
||
}
|
||
|
||
this.uppy.log('[XHRUpload] Uploading...');
|
||
const files = fileIDs.map(fileID => this.uppy.getFile(fileID));
|
||
|
||
if (this.opts.bundle) {
|
||
// if bundle: true, we don’t support remote uploads
|
||
const isSomeFileRemote = files.some(file => file.isRemote);
|
||
|
||
if (isSomeFileRemote) {
|
||
throw new Error('Can’t upload remote files when the `bundle: true` option is set');
|
||
}
|
||
|
||
if (typeof this.opts.headers === 'function') {
|
||
throw new TypeError('`headers` may not be a function when the `bundle: true` option is set');
|
||
}
|
||
|
||
return this.uploadBundle(files);
|
||
}
|
||
|
||
return this.uploadFiles(files).then(() => null);
|
||
}
|
||
|
||
install() {
|
||
if (this.opts.bundle) {
|
||
const {
|
||
capabilities
|
||
} = this.uppy.getState();
|
||
this.uppy.setState({
|
||
capabilities: { ...capabilities,
|
||
individualCancellation: false
|
||
}
|
||
});
|
||
}
|
||
|
||
this.uppy.addUploader(this.handleUpload);
|
||
}
|
||
|
||
uninstall() {
|
||
if (this.opts.bundle) {
|
||
const {
|
||
capabilities
|
||
} = this.uppy.getState();
|
||
this.uppy.setState({
|
||
capabilities: { ...capabilities,
|
||
individualCancellation: true
|
||
}
|
||
});
|
||
}
|
||
|
||
this.uppy.removeUploader(this.handleUpload);
|
||
}
|
||
|
||
}
|
||
|
||
XHRUpload.VERSION = packageJson.version;
|
||
module.exports = XHRUpload; |