ngx-uploadx
Version:
Angular Resumable Upload Module
1,098 lines (1,079 loc) • 39.3 kB
JavaScript
import * as i0 from '@angular/core';
import { InjectionToken, Injectable, inject, NgZone, EventEmitter, Directive, Input, Output, HostListener, HostBinding, ContentChild, NgModule } from '@angular/core';
import { map, debounceTime, takeWhile } from 'rxjs/operators';
import { Subject, fromEvent } from 'rxjs';
function createXhr() {
return new XMLHttpRequest();
}
function releaseXhr(_xhr) {
_xhr = null;
}
class UploadxAjax {
buildXhr;
constructor(buildXhr) {
this.buildXhr = buildXhr;
}
request = ({ method = 'GET', data = null, headers = {}, url, responseType, signal, onUploadProgress, timeout = 0, withCredentials = false, validateStatus = status => status < 400 && status >= 200 }) => {
return new Promise((resolve, reject) => {
const xhr = this.buildXhr();
const abortListener = () => xhr && xhr.readyState !== xhr.DONE && xhr.abort();
signal?.addEventListener('abort', abortListener, { once: true });
xhr.open(method, url, true);
xhr.timeout = timeout;
withCredentials && (xhr.withCredentials = true);
if (responseType && responseType !== 'json') {
xhr.responseType = responseType;
}
Object.keys(headers).forEach(key => xhr.setRequestHeader(key, String(headers[key])));
xhr.upload.onprogress = onUploadProgress || null;
xhr.onerror =
xhr.ontimeout =
xhr.onabort =
evt => {
releaseXhr(xhr);
signal?.removeEventListener('abort', abortListener);
return reject({ error: evt.type, url, method });
};
xhr.onload = () => {
const response = {
data: this.getResponseBody(xhr, responseType),
status: xhr.status,
headers: this.getResponseHeaders(xhr)
};
releaseXhr(xhr);
signal?.removeEventListener('abort', abortListener);
return validateStatus(response.status) ? resolve(response) : reject(response);
};
xhr.send(data);
});
};
getResponseHeaders(xhr) {
const rows = xhr.getAllResponseHeaders().split(/[\r\n]+/);
return rows.reduce((headers, current) => {
const [name, value] = current.split(': ');
name && (headers[name.toLowerCase()] = value);
return headers;
}, {});
}
getResponseBody(xhr, responseType) {
let body = typeof xhr.response === 'undefined' ? xhr.responseText : xhr.response;
if (responseType === 'json' && body && typeof body === 'string') {
try {
body = JSON.parse(body);
}
catch { }
}
return body;
}
}
const UPLOADX_AJAX = new InjectionToken('uploadx.ajax', {
factory: () => new UploadxAjax(createXhr),
providedIn: 'root'
});
/**
* Allows canceling some operation by calling cancel().
* onCancel callback can be used to execute cleanup logic when cancel is called.
*/
class Canceler {
/**
* Callback function to execute cleanup logic when cancel() is called
*/
onCancel = () => { };
/**
* Cancels the operation.
*/
cancel() {
this.onCancel();
this.onCancel = () => { };
}
}
const KiB = 1024;
/**
* Adaptive chunk size
*/
class DynamicChunk {
/** Maximum chunk size in bytes */
static maxSize = Number.MAX_SAFE_INTEGER;
/** Minimum chunk size in bytes */
static minSize = 256 * KiB;
/** Initial chunk size in bytes */
static size = 4 * (256 * KiB);
static minChunkTime = 8;
static maxChunkTime = 24;
/**
* Scales the chunk size based on the throughput.
* If the elapsed time to upload a chunk is less than the min time, increase the chunk size.
* If the elapsed time is more than the max time, decrease the chunk size.
* Keeps the chunk size within the min and max limits.
* @param throughput - represents the upload rate in bytes/sec.
*/
static scale(throughput) {
const elapsedTime = DynamicChunk.size / throughput;
if (elapsedTime < DynamicChunk.minChunkTime) {
DynamicChunk.size = Math.min(DynamicChunk.maxSize, DynamicChunk.size * 2);
}
if (elapsedTime > DynamicChunk.maxChunkTime) {
DynamicChunk.size = Math.max(DynamicChunk.minSize, DynamicChunk.size / 2);
}
return DynamicChunk.size;
}
}
function safeMatch(base, re) {
return (base.match(re) || [''])[0];
}
function resolveUrl(url, base) {
if (url.indexOf('https://') * url.indexOf('http://') === 0)
return url;
if (url.indexOf('//') === 0)
return safeMatch(base, /^(https?:)/) + url;
if (url.indexOf('/') === 0)
return safeMatch(base, /^(?:https?:)?(?:\/\/)?([^\/?]+)/) + url;
return safeMatch(base, /^(?:https?:)?(?:\/\/)?([^\/?]+)?(.*\/)/) + url;
}
/**
* Unwraps a value if it is a function, otherwise returns the value directly.
* Useful for allowing values to optionally be specified as functions.
*/
function unfunc(value, ref) {
return value instanceof Function ? value(ref) : value;
}
const pick = (obj, props) => {
const result = {};
props.forEach(key => (result[key] = obj[key]));
return result;
};
function isNumber(x) {
return x === Number(x);
}
/**
* 32-bit FNV-1a hash function
*/
function createHash(str) {
let hash = 2166136261;
const len = str.length;
for (let i = 0; i < len; i++) {
hash ^= str.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return hash >>> 0;
}
/**
* Utility functions for base64 encoding and decoding strings and objects.
*/
const b64 = {
encode: (str) => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode(Number.parseInt(p1, 16)))),
decode: (str) => decodeURIComponent(atob(str)
.split('')
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')),
serialize: (obj) => Object.keys(obj)
.map(key => [key, b64.encode(String(obj[key]))].filter(Boolean).join(' '))
.toString(),
parse: (encoded) => {
const kvPairs = encoded.split(',').map(kv => kv.split(' '));
const decoded = {};
for (const [key, value] of kvPairs) {
if (key)
decoded[key] = value ? b64.decode(value) : '';
}
return decoded;
}
};
function isBrowser() {
return ![typeof window, typeof navigator].includes('undefined');
}
function onLine() {
return isBrowser() ? navigator.onLine : true;
}
function isIOS() {
return isBrowser() && /iPad|iPhone/.test(navigator.userAgent);
}
function osVersion() {
const m = /OS (\d+)_(\d+)_?(\d+)?/.exec(navigator.userAgent);
if (m?.length)
return m[1] + '.' + m[2] + '.' + m[3] || '0';
return;
}
function compareVersions(a, b) {
return a.localeCompare(b, undefined, { numeric: true });
}
function isBadIOS() {
return isIOS() && compareVersions(osVersion() || '0.0', '15.3') < 0;
}
/**
* [Big files upload error with iOS](https://github.com/kukhariev/ngx-uploadx/issues/316)
* TODO: move to app
*/
const iosOverride = isBadIOS()
? { chunkSize: 0, retryConfig: { shouldRetry: () => false } }
: {};
class IdService {
generateId(uploader) {
const print = JSON.stringify({
...uploader.metadata,
type: uploader.constructor.name,
endpoint: uploader.endpoint
});
return createHash(uploader.name + uploader.size).toString(16) + createHash(print).toString(16);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: IdService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: IdService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: IdService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root'
}]
}] });
var ErrorType;
(function (ErrorType) {
ErrorType[ErrorType["NotFound"] = 0] = "NotFound";
ErrorType[ErrorType["Auth"] = 1] = "Auth";
ErrorType[ErrorType["Retryable"] = 2] = "Retryable";
ErrorType[ErrorType["Fatal"] = 3] = "Fatal";
})(ErrorType || (ErrorType = {}));
const defaultRetryConfig = {
maxAttempts: 8,
shouldRestartCodes: [404, 410],
authErrorCodes: [401],
shouldRetryCodes: [408, 423, 429, 460],
shouldRetry(code) {
return code < 400 || code >= 500 || this.shouldRetryCodes.includes(code);
},
minDelay: 500,
maxDelay: 50000,
onBusyDelay: 1000,
timeout: 0,
keepPartial(statusCode) {
return statusCode >= 400;
}
};
/**
* Retryable ErrorHandler
*/
class RetryHandler {
attempts = 0;
config;
observedValue;
cancel = () => { };
constructor(configOptions = {}) {
this.config = Object.assign({}, defaultRetryConfig, configOptions);
}
/**
* Determine error type based on response code
* @param code - HTTP response status code
*/
kind(code) {
this.attempts++;
if (this.attempts > this.config.maxAttempts) {
return ErrorType.Fatal;
}
if (this.config.authErrorCodes.includes(code)) {
return ErrorType.Auth;
}
if (this.config.shouldRestartCodes.includes(code)) {
return ErrorType.NotFound;
}
if (this.config.shouldRetry(code, this.attempts)) {
return ErrorType.Retryable;
}
return ErrorType.Fatal;
}
/**
* Wait before next retry attempt
* @param time - Delay in ms
*/
wait(time) {
const ms = time || Math.min(2 ** (this.attempts - 1) * this.config.minDelay, this.config.maxDelay);
const jitter = Math.floor(Math.random() * this.config.minDelay * this.attempts);
let id;
return new Promise(resolve => {
this.cancel = () => {
clearTimeout(id);
resolve();
};
id = setTimeout(this.cancel, ms + jitter);
});
}
/**
* Observes value to reset retry attempts counter
* @param value - Value to observe
*/
observe(value) {
this.observedValue !== value && (this.attempts = 0);
this.observedValue = value;
}
}
const HOUR = 1000 * 60 * 60;
class Store {
prefix;
ttl = 24 * HOUR;
constructor(prefix = 'UPLOADX-v4.0-') {
this.prefix = prefix;
}
set(key, value) {
this.ttl &&
localStorage.setItem(this.prefix + key, JSON.stringify([value, Date.now() + this.ttl]));
}
get(key) {
const item = localStorage.getItem(this.prefix + key);
if (item) {
try {
const [value, expires] = JSON.parse(item);
if (Date.now() < expires) {
return value;
}
}
catch { }
this.delete(key);
}
return null;
}
delete(key) {
localStorage.removeItem(this.prefix + key);
}
clear(maxAgeHours = 0) {
this.ttl = maxAgeHours * HOUR;
const now = Date.now();
this.keys().forEach(key => {
const item = localStorage.getItem(key);
if (item && maxAgeHours) {
try {
const [, expires] = JSON.parse(item);
now > expires && localStorage.removeItem(key);
return;
}
catch { }
}
localStorage.removeItem(key);
});
}
keys() {
return Object.keys(localStorage).filter(key => key.indexOf(this.prefix) === 0);
}
}
const store = isLocalStorageAvailable() ? new Store() : new Map();
function isLocalStorageAvailable() {
try {
const key = 'LocalStorageTest';
const value = 'value';
localStorage.setItem(key, value);
const getValue = localStorage.getItem(key);
localStorage.removeItem(key);
return getValue === value;
}
catch {
return false;
}
}
const actionToStatusMap = {
pause: 'paused',
upload: 'queue',
cancel: 'cancelled',
update: 'updated'
};
/**
* Uploader Base Class
*/
class Uploader {
file;
options;
stateChange;
ajax;
name;
size;
uploadId;
response = null;
responseStatus = 0;
responseHeaders = {};
progress = 0;
remaining;
speed = 0;
/** Custom headers */
headers = {};
/** Metadata Object */
metadata;
/** Upload endpoint */
endpoint = '/upload';
/** Chunk size in bytes */
chunkSize = 0;
/** Auth token/tokenGetter */
token;
/** Byte offset within the whole file */
offset;
/** Retries handler */
retry;
canceler = new Canceler();
abortController = new AbortController();
/** Set HttpRequest responseType */
responseType;
_authorize;
_prerequest;
_token;
_eventsCount = 0;
constructor(file, options, stateChange, ajax) {
this.file = file;
this.options = options;
this.stateChange = stateChange;
this.ajax = ajax;
this.retry = new RetryHandler(options.retryConfig);
this.name = file.name;
this.size = file.size;
this.metadata = {
name: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
lastModified: file.lastModified
};
options.maxChunkSize && (DynamicChunk.maxSize = options.maxChunkSize);
/** A function that is executed before the request is sent */
this._prerequest = options.prerequest || (req => req);
/** A function used to authorize the request */
this._authorize = options.authorize || (req => req);
this.configure(options);
}
_url = '';
get url() {
return this._url || store.get(this.uploadId) || '';
}
set url(value) {
this._url !== value && store.set(this.uploadId, value);
this._url = value;
}
_status;
get status() {
return this._status;
}
set status(s) {
if (s !== 'updated' && s !== 'cancelled') {
if (this._status === s)
return;
if (this._status === 'complete')
return;
}
if (this._status === 'cancelled')
return;
if (this._status === 'uploading' && s === 'queue')
return;
if (this._status === 'retry')
this.retry.cancel();
this._status = s;
if (s === 'paused')
this.abort();
if (s === 'cancelled' || s === 'complete' || s === 'error')
this.cleanup();
if (s === 'cancelled')
this.cancelAndSendState();
else if (s === 'updated')
this.updateAndSendState();
else
this.stateChange(this);
}
/**
* Configure uploader
*/
configure({ metadata, headers, token, endpoint, action }) {
endpoint && (this.endpoint = endpoint);
token && (this.token = token);
metadata && Object.assign(this.metadata, unfunc(metadata, this.file));
headers && Object.assign(this.headers, unfunc(headers, this.file));
action && (this.status = actionToStatusMap[action]);
}
/**
* Starts uploading
*/
async upload() {
do {
this.status = 'uploading';
try {
this._token ||= await this.updateToken();
this.url ||= await this.getFileUrl();
if (this.offset !== this.size) {
this.offset = isNumber(this.offset)
? await this.sendFileContent()
: await this.getOffset();
}
this.retry.observe(this.offset);
if (this.offset === this.size) {
this.remaining = 0;
this.progress = 100;
this.status = 'complete';
}
else if (!isNumber(this.offset)) {
this.stateChange(this);
await this.retry.wait(this.getRetryAfterFromBackend() || this.retry.config.onBusyDelay);
}
}
catch (e) {
e instanceof Error && console.error(e);
if (this.status !== 'uploading') {
return;
}
switch (this.retry.kind(this.responseStatus)) {
case ErrorType.Fatal:
this.status = 'error';
return;
case ErrorType.NotFound:
this.url = '';
break;
case ErrorType.Auth:
this._token = '';
break;
default:
if (unfunc(this.retry.config.keepPartial, this.responseStatus)) {
this.offset = undefined;
}
this.status = 'retry';
await this.retry.wait(this.getRetryAfterFromBackend());
}
}
} while (['uploading', 'retry', 'updated'].includes(this._status));
}
/**
* Performs http requests
*/
async request(requestOptions) {
this.responseStatus = 0;
this.response = null;
this.responseHeaders = {};
if (this.abortController.signal.aborted) {
this.abortController = new AbortController();
}
const signal = requestOptions.signal || this.abortController.signal;
let req = {
body: requestOptions.body || null,
canceler: this.canceler,
signal,
headers: { ...this.headers, ...requestOptions.headers },
method: requestOptions.method || 'GET',
url: requestOptions.url || this.url
};
if (!requestOptions.skipAuthorization) {
req = await this._authorize(req, this._token);
}
const { body = null, headers, method, url = req.url } = (await this._prerequest(req)) || req;
const ajaxRequestConfig = {
method,
headers: { ...req.headers, ...headers },
url,
data: body,
responseType: this.options.responseType ?? this.responseType,
withCredentials: !!this.options.withCredentials,
canceler: this.canceler,
signal,
validateStatus: () => true,
timeout: this.retry.config.timeout
};
if (isNumber(this.offset) && body && typeof body === 'object') {
ajaxRequestConfig.onUploadProgress = this.onProgress();
}
const response = await this.ajax.request(ajaxRequestConfig);
this.response = response.data;
this.responseHeaders = response.headers;
this.responseStatus = response.status;
if (response.status >= 400) {
return Promise.reject();
}
}
/**
* Set auth token string
*/
updateToken = () => {
return unfunc(this.token || '', this.responseStatus);
};
/**
* Updating the metadata of the upload
*/
update(_data) {
return Promise.reject('Not implemented');
}
abort() {
this.offset = undefined;
this.abortController.abort();
this.canceler.cancel();
}
async cancel() {
this.abort();
if (this.url) {
await this.request({ method: 'DELETE' }).catch(() => { });
}
}
/**
* Gets the value from the response
*/
getValueFromResponse(key) {
return this.responseHeaders[key.toLowerCase()] || null;
}
/**
* Get file chunk
* @param offset - number of bytes of the file to skip
* @param size - chunk size
*/
getChunk(offset, size) {
if (this.responseStatus === 413) {
DynamicChunk.maxSize = DynamicChunk.size = Math.floor(DynamicChunk.size / 2);
}
this.chunkSize =
this.options.chunkSize === 0 ? this.size : this.options.chunkSize || DynamicChunk.size;
const start = offset ?? this.offset ?? 0;
const end = Math.min(start + (size || this.chunkSize), this.size);
const body = this.file.slice(start, end);
return { start, end, body };
}
getRetryAfterFromBackend() {
return Number(this.getValueFromResponse('retry-after')) * 1000;
}
cancelAndSendState() {
this.cancel().then(() => this.stateChange(this), console.error);
}
updateAndSendState() {
this.update({ metadata: this.metadata }).then(() => this.stateChange(this), console.error);
}
cleanup = () => {
store.delete(this._url);
store.delete(this.uploadId);
};
onProgress() {
let throttle;
const startTime = Date.now();
return ({ loaded }) => {
const current = loaded / ((Date.now() - startTime) / 1000);
this.speed = ~~((this.speed * this._eventsCount + current) / ++this._eventsCount);
DynamicChunk.scale(this.speed);
if (!throttle) {
throttle = setTimeout(() => (throttle = undefined), 500);
const uploaded = (this.offset || 0) + loaded;
this.progress = +((uploaded / this.size) * 100).toFixed(2);
this.remaining = ~~((this.size - uploaded) / this.speed);
this.stateChange(this);
}
};
}
}
/**
* Implements XHR/CORS Resumable Upload
* {@link https://github.com/kukhariev/node-uploadx/blob/master/proto.md Github}
* @see {@link https://developers.google.com/drive/api/v3/manage-uploads#resumable Google Drive API documentation}
*/
class UploaderX extends Uploader {
responseType = 'json';
async getFileUrl() {
const body = JSON.stringify(this.metadata);
const headers = {
'Content-Type': 'application/json; charset=utf-8',
'X-Upload-Content-Length': this.size,
'X-Upload-Content-Type': this.file.type || 'application/octet-stream'
};
await this.request({ method: 'POST', body, url: this.endpoint, headers });
this.offset = this.getOffsetFromResponse() || (this.responseStatus === 201 ? 0 : undefined);
const location = this.getValueFromResponse('location');
if (!location) {
throw new Error('Invalid or missing Location header');
}
return resolveUrl(location, this.endpoint);
}
async sendFileContent() {
const { body, start, end } = this.getChunk();
const headers = {
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes ${start}-${end - 1}/${this.size}`
};
await this.request({ method: 'PUT', body, headers });
return this.responseStatus > 201 ? this.getOffsetFromResponse() : end;
}
async getOffset() {
const headers = {
'Content-Type': 'application/octet-stream',
'Content-Range': `bytes */${this.size}`
};
await this.request({ method: 'PUT', headers });
return this.responseStatus > 201 ? this.getOffsetFromResponse() : this.size;
}
async update(data) {
const body = JSON.stringify(data);
const headers = { 'Content-Type': 'application/json; charset=utf-8' };
await this.request({ method: 'PATCH', body, headers });
const location = this.getValueFromResponse('location') || this.url;
return resolveUrl(location, this.endpoint);
}
getOffsetFromResponse() {
const range = this.getValueFromResponse('Range');
return range ? getRangeEnd(range) + 1 : undefined;
}
}
function getRangeEnd(range = '') {
const end = parseInt(range.split(/-/)[1], 10);
return end >= 0 ? end : -1;
}
const defaultOptions = {
endpoint: '/upload',
autoUpload: true,
concurrency: 2,
uploaderClass: UploaderX,
authorize: (req, token) => {
token && (req.headers['Authorization'] = `Bearer ${token}`);
return req;
},
storeIncompleteHours: 24
};
const UPLOADX_FACTORY_OPTIONS = new InjectionToken('uploadx.factory.options', { providedIn: 'root', factory: () => defaultOptions });
const UPLOADX_OPTIONS = new InjectionToken('uploadx.options');
/**
* Implements tus resumable upload protocol
* {@link https://github.com/tus/tus-resumable-upload-protocol/blob/master/protocol.md Github}
*/
class Tus extends Uploader {
headers = { 'Tus-Resumable': '1.0.0' };
async getFileUrl() {
const encodedMetaData = b64.serialize(this.metadata);
const headers = {
'Upload-Length': this.size,
'Upload-Metadata': encodedMetaData
};
await this.request({ method: 'POST', url: this.endpoint, headers });
this.offset = this.getOffsetFromResponse() || (this.responseStatus === 201 ? 0 : undefined);
const location = this.getValueFromResponse('location');
if (!location) {
throw new Error('Invalid or missing Location header');
}
return resolveUrl(location, this.endpoint);
}
async sendFileContent() {
const { body, start, end } = this.getChunk();
const headers = {
'Content-Type': 'application/offset+octet-stream',
'Upload-Offset': start
};
await this.request({ method: 'PATCH', body, headers });
return this.getOffsetFromResponse() || end;
}
async getOffset() {
await this.request({ method: 'HEAD' });
return this.getOffsetFromResponse();
}
getOffsetFromResponse() {
const offset = this.getValueFromResponse('Upload-Offset');
return offset ? parseInt(offset, 10) : undefined;
}
}
const UPLOAD_STATE_KEYS = [
'file',
'name',
'progress',
'remaining',
'response',
'responseHeaders',
'responseStatus',
'size',
'speed',
'status',
'uploadId',
'url'
];
const DUE_TIME = 5;
class UploadxService {
/** Upload Queue */
queue = [];
options;
eventsStream = new Subject();
subs = [];
ngZone = inject(NgZone);
ajax = inject(UPLOADX_AJAX);
idService = inject(IdService);
constructor() {
const options = inject(UPLOADX_OPTIONS, { optional: true });
const defaults = inject(UPLOADX_FACTORY_OPTIONS);
this.options = Object.assign({}, defaults, options);
if (isBrowser()) {
this.subs.push(fromEvent(window, 'online').subscribe(() => this.control({ action: 'upload' })), fromEvent(window, 'offline').subscribe(() => this.control({ action: 'pause' })));
}
}
/** Upload status events */
get events() {
return this.eventsStream.asObservable();
}
/**
* Initializes service
* @param options global module options
* @returns Observable that emits a new value on progress or status changes
*/
init(options = {}) {
Object.assign(this.options, options);
return this.events;
}
/**
* Initializes service
* @param options global module options
* @returns Observable that emits the current array of uploaders
*/
connect(options) {
return this.init(options).pipe(map(() => this.queue), debounceTime(DUE_TIME));
}
/**
* Terminates all uploads and clears the queue
*/
disconnect() {
this.queue.forEach(uploader => (uploader.status = 'paused'));
this.queue = [];
}
/**
* Returns current uploads state
* @example
* // restore background uploads
* this.uploads = this.uploadService.state();
*/
state() {
return this.queue.map(uploader => pick(uploader, UPLOAD_STATE_KEYS));
}
ngOnDestroy() {
this.disconnect();
this.subs.forEach(sub => sub.unsubscribe());
}
/**
* Creates uploaders for files and adds them to the upload queue
*/
handleFiles(files, options = {}) {
const instanceOptions = { ...this.options, ...iosOverride, ...options };
store.clear(instanceOptions.storeIncompleteHours);
this.options.concurrency = instanceOptions.concurrency;
('name' in files ? [files] : Array.from(files)).forEach(file => this.addUploaderInstance(file, instanceOptions));
}
/**
* Upload control
* @example
* // pause all
* this.uploadService.control({ action: 'pause' });
* // pause upload with uploadId
* this.uploadService.control({ action: 'pause', uploadId});
* // set token
* this.uploadService.control({ token: `TOKEN` });
*/
control(evt) {
const target = evt.uploadId
? this.queue.filter(({ uploadId }) => uploadId === evt.uploadId)
: this.queue;
target.forEach(uploader => uploader.configure(evt));
}
/**
* Number of active uploads
*/
get activeUploadsCount() {
return this.queue.filter(({ status }) => status === 'uploading' || status === 'retry').length;
}
/**
* Performs http requests
*/
async request(config) {
config.data ||= config.body;
return this.ajax.request(config);
}
stateChange = (uploader) => {
this.ngZone.run(() => this.eventsStream.next(pick(uploader, UPLOAD_STATE_KEYS)));
if (uploader.status !== 'uploading' && uploader.status !== 'added') {
this.ngZone.runOutsideAngular(() => setTimeout(() => this.processQueue()));
}
};
async addUploaderInstance(file, options) {
const uploader = new options.uploaderClass(file, options, this.stateChange, this.ajax);
uploader.uploadId = await this.idService.generateId(uploader);
this.queue.push(uploader);
uploader.status = 'added';
if (options.autoUpload && onLine()) {
uploader.status = 'queue';
}
}
processQueue() {
this.queue = this.queue.filter(({ status }) => status !== 'cancelled');
this.queue
.filter(({ status }) => status === 'queue')
.slice(0, Math.max(this.options.concurrency - this.activeUploadsCount, 0))
.forEach(uploader => uploader.upload());
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxService, providedIn: 'root' });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [] });
class UploadxDirective {
elementRef;
renderer;
uploadService;
set uploadx(value) {
if (value) {
this.options = value;
}
}
options = {};
set control(value) {
if (value) {
this.uploadService.control(value);
}
}
state = new EventEmitter();
constructor(elementRef, renderer, uploadService) {
this.elementRef = elementRef;
this.renderer = renderer;
this.uploadService = uploadService;
}
ngOnInit() {
const { multiple, allowedTypes } = { ...this.uploadService.options, ...this.options };
multiple !== false && this.renderer.setAttribute(this.elementRef.nativeElement, 'multiple', '');
allowedTypes &&
this.renderer.setAttribute(this.elementRef.nativeElement, 'accept', allowedTypes);
this.uploadService.events
.pipe(takeWhile(() => this.state.observers.length > 0))
.subscribe(this.state);
}
fileListener(files) {
if (files?.length) {
this.uploadService.handleFiles(files, this.options);
}
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxDirective, deps: [{ token: i0.ElementRef }, { token: i0.Renderer2 }, { token: UploadxService }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: UploadxDirective, isStandalone: true, selector: "[uploadx]", inputs: { uploadx: "uploadx", options: "options", control: "control" }, outputs: { state: "state" }, host: { listeners: { "change": "fileListener($event.target.files)" } }, ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxDirective, decorators: [{
type: Directive,
args: [{
selector: '[uploadx]',
standalone: true
}]
}], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.Renderer2 }, { type: UploadxService }], propDecorators: { uploadx: [{
type: Input
}], options: [{
type: Input
}], control: [{
type: Input
}], state: [{
type: Output
}], fileListener: [{
type: HostListener,
args: ['change', ['$event.target.files']]
}] } });
class UploadxDropDirective {
uploadService;
active = false;
fileInput;
constructor(uploadService) {
this.uploadService = uploadService;
}
dropHandler(event) {
this._stopEvents(event);
this.active = false;
const files = this.getFiles(event);
if (files.length) {
this.fileInput ? this.fileInput.fileListener(files) : this.uploadService.handleFiles(files);
}
}
onDragOver(event) {
this._stopEvents(event);
if (event.dataTransfer?.items[0]?.kind === 'file') {
if (this.fileInput?.options.multiple === false && event.dataTransfer.items.length > 1) {
event.dataTransfer.dropEffect = 'none';
}
else {
event.dataTransfer.dropEffect = 'copy';
this.active = true;
}
}
}
onDragLeave(event) {
this._stopEvents(event);
this.active = false;
}
/**
* Extracts the files from a `DragEvent` object
*/
getFiles(event) {
const dataTransfer = new DataTransfer();
const items = event.dataTransfer?.items;
if (items?.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file' && !item.webkitGetAsEntry()?.isDirectory) {
const file = item.getAsFile();
file && dataTransfer.items.add(file);
}
}
}
return dataTransfer.files;
}
_stopEvents(event) {
event.stopPropagation();
event.preventDefault();
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxDropDirective, deps: [{ token: UploadxService }], target: i0.ɵɵFactoryTarget.Directive });
static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.3.12", type: UploadxDropDirective, isStandalone: true, selector: "[uploadxDrop]", host: { listeners: { "drop": "dropHandler($event)", "dragover": "onDragOver($event)", "dragleave": "onDragLeave($event)" }, properties: { "class.uploadx-drop-active": "this.active" } }, queries: [{ propertyName: "fileInput", first: true, predicate: UploadxDirective, descendants: true }], ngImport: i0 });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxDropDirective, decorators: [{
type: Directive,
args: [{
selector: '[uploadxDrop]',
standalone: true
}]
}], ctorParameters: () => [{ type: UploadxService }], propDecorators: { active: [{
type: HostBinding,
args: ['class.uploadx-drop-active']
}], fileInput: [{
type: ContentChild,
args: [UploadxDirective, { static: false }]
}], dropHandler: [{
type: HostListener,
args: ['drop', ['$event']]
}], onDragOver: [{
type: HostListener,
args: ['dragover', ['$event']]
}], onDragLeave: [{
type: HostListener,
args: ['dragleave', ['$event']]
}] } });
class UploadxModule {
static withConfig(options) {
return {
ngModule: UploadxModule,
providers: [{ provide: UPLOADX_OPTIONS, useValue: options }]
};
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
static ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "17.3.12", ngImport: i0, type: UploadxModule, imports: [UploadxDirective, UploadxDropDirective], exports: [UploadxDirective, UploadxDropDirective] });
static ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxModule });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: UploadxModule, decorators: [{
type: NgModule,
args: [{
imports: [UploadxDirective, UploadxDropDirective],
exports: [UploadxDirective, UploadxDropDirective]
}]
}] });
/**
* Provides configuration options for standalone app.
*
* @example
* ```ts
* bootstrapApplication(AppComponent, {
* providers: [
* provideUploadx({
* endpoint: uploadUrl,
* allowedTypes: 'video/*,audio/*',
* maxChunkSize: 96 * 1024 * 1024
* })
* ]
* });
* ```
*/
function provideUploadx(options = {}) {
const providers = [
{
provide: UPLOADX_OPTIONS,
useValue: options
}
];
return providers;
}
/**
* Generated bundle index. Do not edit.
*/
export { Canceler, DynamicChunk, ErrorType, IdService, RetryHandler, Store, Tus, UPLOADX_AJAX, UPLOADX_FACTORY_OPTIONS, UPLOADX_OPTIONS, UPLOAD_STATE_KEYS, Uploader, UploaderX, UploadxAjax, UploadxDirective, UploadxDropDirective, UploadxModule, UploadxService, b64, compareVersions, createHash, getRangeEnd, iosOverride, isBadIOS, isBrowser, isIOS, isLocalStorageAvailable, isNumber, onLine, osVersion, pick, provideUploadx, resolveUrl, store, unfunc };
//# sourceMappingURL=ngx-uploadx.mjs.map