@akanass/rx-file-upload
Version:
Library to upload a file in the browser and send it fully or in several chunks to the server.
196 lines (193 loc) • 11.1 kB
JavaScript
import { ajax } from 'rxjs/ajax';
import { Subject, from, of, throwError, merge } from 'rxjs';
import { distinctUntilChanged, mergeMap, filter, toArray, defaultIfEmpty, tap, concatMap, catchError, map } from 'rxjs/operators';
import { SHA256 } from 'crypto-es/lib/sha256';
import { WordArray } from 'crypto-es/lib/core';
const supportsRxFileUpload = () => typeof window !== 'undefined' &&
typeof XMLHttpRequest === 'function' &&
typeof FormData === 'function';
class RxFileUploadCls {
_allowedConfigProperties = [
'url',
'method',
'headers',
'timeout',
'user',
'password',
'crossDomain',
'withCredentials',
'xsrfCookieName',
'xsrfHeaderName',
'responseType',
'queryParams',
];
_allowedAjaxMethods = ['POST', 'PUT'];
_defaultAjaxMethod = 'POST';
_oneKb = 1024;
_config;
_ajax;
_chunkSize = this._oneKb * this._oneKb;
_addChecksum = false;
_useChunks = false;
_chunkMimeType = 'application/octet-stream';
_disableProgressCompletion = false;
_numberOfFilesToUpload = 0;
_progress$;
_rxFileUploadIds = [];
_rxFileUploadIdHeaderName = 'X-RxFileUpload-ID';
constructor(config) {
if (!supportsRxFileUpload())
throw new Error('You must be in a compatible browser to use this library !!!');
if (typeof config?.url !== 'string')
throw new Error('You must at least provide the url in the configuration !!!');
if (!this._allowedAjaxMethods.includes(config.method?.toUpperCase()))
config.method = this._defaultAjaxMethod;
else
config.method = config.method.toUpperCase();
if (typeof config.chunkSize === 'number') {
if (config.chunkSize % this._oneKb !== 0)
throw new Error('The size of a chunk must be a multiple of 1024 bytes / 1 Kb !!!');
this._chunkSize = config.chunkSize;
delete config.chunkSize;
}
if (typeof config.addChecksum === 'boolean') {
this._addChecksum = config.addChecksum;
delete config.addChecksum;
}
if (typeof config.useChunks === 'boolean') {
this._useChunks = config.useChunks;
delete config.useChunks;
}
if (typeof config.disableProgressCompletion === 'boolean') {
this._disableProgressCompletion = config.disableProgressCompletion;
delete config.disableProgressCompletion;
}
this._setAjaxConfig(config);
this._ajax = ajax;
this._progress$ = new Subject();
}
get progress$() {
return this._progress$.pipe(distinctUntilChanged());
}
upload = (oneFileOrMultipleFiles, additionalFormData) => this._checkInstanceProcess().pipe(mergeMap(() => from([].concat(oneFileOrMultipleFiles)).pipe(filter((file) => file instanceof File), toArray(), mergeMap((files) => of(files.length).pipe(filter((length) => length === 0), mergeMap(() => throwError(() => new Error('You must provide at least one file to upload.'))), defaultIfEmpty(files))), tap(() => (this._rxFileUploadIds = [])), tap((files) => (this._numberOfFilesToUpload = files.length)), mergeMap((files) => from(files).pipe(mergeMap((file, fileIndex) => this._uploadFile(file, additionalFormData, files.length > 1 ? fileIndex : undefined)))))));
_checkInstanceProcess = () => of(this._numberOfFilesToUpload).pipe(filter((numberOfFilesToUpload) => numberOfFilesToUpload !== 0), mergeMap(() => throwError(() => new Error('Files are already being uploaded for this instance of "RxFileUpload". You must either create a new instance before calling "upload" method or send them all together.'))), defaultIfEmpty(undefined));
_uploadFile = (file, additionalFormData, fileIndex) => this._generateRxFileUploadId(fileIndex).pipe(mergeMap(() => merge(of(this._useChunks).pipe(filter((useChunks) => !!useChunks), mergeMap(() => this._sendFileWithChunks(file, additionalFormData, fileIndex))), of(this._useChunks).pipe(filter((useChunks) => !useChunks), mergeMap(() => this._sendFile(file, additionalFormData, fileIndex))))));
_sendFile = (file, additionalFormData, fileIndex) => this._fileBodyData(file, additionalFormData, fileIndex).pipe(mergeMap((f) => this._makeAjaxCall(f, undefined, fileIndex)));
_sendFileWithChunks = (file, additionalFormData, fileIndex) => this._chunkBodyData(file, additionalFormData, fileIndex).pipe(concatMap((f) => this._makeAjaxCall(f.formData, f.chunkSequenceData, fileIndex)));
_makeAjaxCall = (f, chunk, fileIndex) => this._ajax(this._buildConfig(f, fileIndex)).pipe(catchError((e) => throwError(() => ({
status: e.status,
response: e.response,
}))), mergeMap((ajaxResponse) => merge(of(ajaxResponse.type).pipe(filter((type) => type === 'upload_progress'), map(() => ({
progress: this._calculateProgress(Math.round((ajaxResponse.loaded * 100) / ajaxResponse.total), chunk),
})), mergeMap((progress) => of(fileIndex).pipe(filter((_) => typeof _ === 'number'), map(() => ({
...progress,
fileIndex,
})), defaultIfEmpty(progress))), tap((progress) => this._progress$.next(progress)), map(() => ajaxResponse)), of(ajaxResponse.type).pipe(filter((type) => type === 'upload_load'), filter(() => typeof chunk === 'undefined' ||
chunk.sequence === chunk.totalChunks), tap(() => this._numberOfFilesToUpload--), tap(() => this._numberOfFilesToUpload === 0 &&
!this._disableProgressCompletion
? this._progress$.complete()
: undefined), map(() => ajaxResponse)), of(ajaxResponse.type).pipe(filter((type) => type === 'download_load'), map(() => ajaxResponse)))), filter((ajaxResponse) => ajaxResponse.type === 'download_load'), map((ajaxResponse) => ({
status: ajaxResponse.status,
response: ajaxResponse.response,
responseHeaders: Object.keys(ajaxResponse.responseHeaders)
.filter((key) => key !== '')
.reduce((acc, curr) => ({
...acc,
[curr]: ajaxResponse.responseHeaders[curr],
}), {}),
})), mergeMap((response) => of(fileIndex).pipe(filter((_) => typeof _ === 'number'), map(() => ({ ...response, fileIndex })), defaultIfEmpty(response))));
_calculateProgress = (progress, chunk) => typeof chunk === 'object'
? Math.round(progress / chunk.totalChunks +
(chunk.sequence - 1) * (100 / chunk.totalChunks))
: progress;
_fileBodyData = (file, additionalFormData, fileIndex) => this._fileDataWithAdditionalData(file, additionalFormData, fileIndex).pipe(map((data) => ({
formData: new FormData(),
data: { ...data, file },
})), map((_) => {
Object.keys(_.data).forEach((key) => _.formData.append(key, _.data[key]));
return _.formData;
}));
_fileDataWithAdditionalData = (file, additionalFormData, fileIndex) => of({
name: file.name,
size: file.size,
lastModified: file.lastModified,
type: file.type,
}).pipe(mergeMap((fileData) => of(this._addChecksum).pipe(filter((addChecksum) => !!addChecksum), mergeMap(() => this._calculateCheckSum(file).pipe(map((checksum) => ({
...fileData,
sha256Checksum: checksum,
})))), defaultIfEmpty(fileData))), map((fileData) => ({
rxFileUploadId: this._rxFileUploadIds[typeof fileIndex === 'number' ? fileIndex : 0],
fileData: this._serialize(fileData),
})), map((data) => typeof additionalFormData !== 'undefined' &&
typeof additionalFormData.fieldName === 'string' &&
['string', 'object'].includes(typeof additionalFormData.data)
? {
...data,
[additionalFormData.fieldName]: this._serialize(additionalFormData.data),
}
: data));
_chunkBodyData = (file, additionalFormData, fileIndex) => this._fileDataWithAdditionalData(file, additionalFormData, fileIndex).pipe(mergeMap((fileData) => this._calculateChunkSizes(file.size).pipe(mergeMap((chunkSizes) => from(chunkSizes).pipe(map((_, index) => ({
data: {
...fileData,
chunkData: this._serialize({
name: `${file.name}.part${index + 1}`,
size: _.endByte - _.startByte,
lastModified: file.lastModified,
type: this._chunkMimeType,
sequence: index + 1,
totalChunks: chunkSizes.length,
startByte: _.startByte,
endByte: _.endByte,
}),
file: new File([file.slice(_.startByte, _.endByte)], `${file.name}.part${index + 1}`, {
type: this._chunkMimeType,
lastModified: file.lastModified,
}),
},
formData: new FormData(),
chunkSequenceData: {
sequence: index + 1,
totalChunks: chunkSizes.length,
},
})), map((_) => {
Object.keys(_.data).forEach((key) => _.formData.append(key, _.data[key]));
return {
formData: _.formData,
chunkSequenceData: _.chunkSequenceData,
};
}))))));
_setAjaxConfig = (config) => {
Object.keys(config).forEach((_) => {
if (!this._allowedConfigProperties.includes(_))
throw new Error(`"${_}" isn't a valid property of "RxFileUploadConfig"`);
});
this._config = {
...(!!config.headers
? {
...config,
headers: Object.keys(config.headers)
.filter((_) => _.toLowerCase() !== 'content-type')
.reduce((acc, curr) => ({ ...acc, [curr]: config.headers[curr] }), {}),
}
: { ...config }),
includeUploadProgress: true,
};
};
_buildConfig = (f, fileIndex) => ({
...this._config,
headers: {
...this._config.headers,
[this._rxFileUploadIdHeaderName]: this._rxFileUploadIds[typeof fileIndex === 'number' ? fileIndex : 0],
},
body: f,
});
_calculateChunkSizes = (fileSize) => from(Array.from({ length: Math.max(Math.ceil(fileSize / this._chunkSize), 1) }, (_, offset) => offset++)).pipe(map((offset) => ({
startByte: offset * this._chunkSize,
endByte: Math.min(fileSize, (offset + 1) * this._chunkSize),
})), toArray());
_calculateCheckSum = (file) => from(file.arrayBuffer()).pipe(map((arrayBuffer) => SHA256(WordArray.create(new Uint8Array(arrayBuffer))).toString()));
_generateRxFileUploadId = (fileIndex) => of(new Date().getTime() * Math.floor(Math.random() * 10 + 1)).pipe(map((transactionId) => SHA256(`${transactionId}`).toString()), tap((rxFileUploadId) => (this._rxFileUploadIds[typeof fileIndex === 'number' ? fileIndex : 0] = rxFileUploadId)), map(() => undefined));
_serialize = (data) => typeof data === 'string' ? data : JSON.stringify(data);
}
const rxFileUpload = (config) => new RxFileUploadCls(config);
export { rxFileUpload, supportsRxFileUpload };