UNPKG

@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
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 };