diff --git a/package.json b/package.json index eef5a29..511916e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ztimson/utils", - "version": "0.30.8", + "version": "0.30.9", "description": "Utility library", "author": "Zak Timson", "license": "MIT", diff --git a/src/files.ts b/src/files.ts index 3321f59..323755e 100644 --- a/src/files.ts +++ b/src/files.ts @@ -82,11 +82,11 @@ export function timestampFilename(name?: string, date: Date | number | string = } /** - * Upload file to URL with progress callback using PromiseProgress + * Upload file to URL with progress (size/total/speed/eta/...) via PromiseProgress * Works in both browser (with progress) and Node.js (fallback without progress) * * @param {{url: string, files: File[], headers?: {[p: string]: string}, withCredentials?: boolean}} options - * @return {PromiseProgress} Promise of request with `onProgress` callback + * @return {PromiseProgress} Promise of request with `onProgress` callback & meta getters */ export function uploadWithProgress(options: { url: string; @@ -102,7 +102,7 @@ export function uploadWithProgress(options: { options.files.forEach(f => formData.append('files', f)); xhr.withCredentials = !!options.withCredentials; - xhr.upload.addEventListener('progress', (event) => event.lengthComputable ? prog(event.loaded / event.total) : null); + xhr.upload.addEventListener('progress', (event) => event.lengthComputable ? prog(event.loaded, event.total) : null); xhr.addEventListener('loadend', () => res(JSONAttemptParse(xhr.responseText))); xhr.addEventListener('error', () => rej(JSONAttemptParse(xhr.responseText))); xhr.addEventListener('timeout', () => rej({error: 'Request timed out'})); @@ -131,3 +131,39 @@ export function uploadWithProgress(options: { } }); } + +/** + * Download file from URL with progress (size/total/speed/eta/...) via PromiseProgress + * Streams the response body for progress when supported, falls back to a plain blob otherwise + * + * @param {{url: string, name?: string, headers?: {[p: string]: string}}} options name - if set, triggers a file save via `downloadFile` + * @return {PromiseProgress} Promise of downloaded blob with `onProgress` callback & meta getters + */ +export function downloadWithProgress(options: {url: string; name?: string; headers?: {[key: string]: string}}): PromiseProgress { + return new PromiseProgress(async (res, rej, prog) => { + try { + const response = await fetch(options.url, {headers: options.headers || {}}); + if(!response.ok) return rej(JSONAttemptParse(await response.text())); + const total = Number(response.headers.get('content-length')) || 0; + if(!response.body?.getReader) { + const blob = await response.blob(); + options.name && downloadFile(blob, options.name); + return res(blob); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while(true) { + const {done, value} = await reader.read(); + if(done) break; + chunks.push(value); + prog(size += value.length, total); + } + const blob = new Blob(chunks as BlobPart[]); + options.name && downloadFile(blob, options.name); + res(blob); + } catch (error) { + rej(error); + } + }); +} diff --git a/src/promise-progress.ts b/src/promise-progress.ts index 13076fa..afe3cec 100644 --- a/src/promise-progress.ts +++ b/src/promise-progress.ts @@ -1,65 +1,83 @@ -export type ProgressCallback = (progress: number) => any; +export interface ProgressMeta { + start: number; + end: number | null; + elapsed: number; // seconds + speed: number; // bytes/sec + progress: number; // 0-1 + size: number; // bytes transferred + total: number; // total bytes + eta: number | null; // seconds remaining +} + +export type ProgressCallback = (meta: ProgressMeta) => any; /** - * A promise that fires the `onProgress` callback on incremental progress + * A promise that tracks transfer progress (size/total/speed/eta/elapsed) via `onProgress` callback & direct getters * * @example * ```js - * const promise = new Promise((resolve, reject, progress) => { - * const max = 10; - * for(let i = 0; i < max; i++) progress(i / max); + * const promise = new PromiseProgress((resolve, reject, progress) => { + * const total = 100; + * for(let i = 0; i <= total; i++) progress(i, total); * resolve(1); * }); * - * console.log(promise.progress); + * console.log(promise.progress, promise.speed, promise.eta); * - * promise.onProgress(console.log) + * promise.onProgress(m => console.log(m.progress, m.eta)) * .then(console.log) * .catch(console.error) * .finally(...); - * * ``` */ export class PromiseProgress extends Promise { private listeners: ProgressCallback[] = []; + private _meta: ProgressMeta = {start: Date.now(), end: null, elapsed: 0, speed: 0, progress: 0, size: 0, total: 0, eta: null}; - private _progress = 0; - get progress() { return this._progress; } - set progress(p: number) { - if(p == this._progress) return; - this._progress = p; - this.listeners.forEach(l => l(p)); + get meta() { return this._meta; } + get start() { return this._meta.start; } + get end() { return this._meta.end; } + get elapsed() { return this._meta.elapsed; } + get speed() { return this._meta.speed; } + get progress() { return this._meta.progress; } + get size() { return this._meta.size; } + get total() { return this._meta.total; } + get eta() { return this._meta.eta; } + + private update(size: number, total: number) { + const elapsed = (Date.now() - this._meta.start) / 1000; + const speed = elapsed ? size / elapsed : 0; + const progress = total ? size / total : this._meta.progress; + this._meta = {...this._meta, size, total, progress, elapsed, speed, eta: speed && total ? (total - size) / speed : null}; + (this.listeners ||= []).forEach(l => l(this._meta)); } - constructor(executor: (resolve: (value: T) => any, reject: (reason: any) => void, progress: (progress: number) => any) => void) { + constructor(executor: (resolve: (value: T) => any, reject: (reason: any) => void, progress: (size: number, total?: number) => any) => void) { super((resolve, reject) => executor( - (value: T) => resolve(value), - (reason: any) => reject(reason), - (progress: number) => this.progress = progress, + (value: T) => { this._meta = {...this._meta, end: Date.now()}; resolve(value); }, + (reason: any) => { this._meta = {...this._meta, end: Date.now()}; reject(reason); }, + (size: number, total?: number) => this.update(size, total ?? this._meta.total), )); } static from(promise: Promise): PromiseProgress { if(promise instanceof PromiseProgress) return promise; - return new PromiseProgress((res, rej) => promise - .then((...args) => res(...args)) - .catch((...args) => rej(...args))); + return new PromiseProgress((res, rej) => promise.then(res).catch(rej)); } private from(promise: Promise): PromiseProgress { const newPromise = PromiseProgress.from(promise); - this.onProgress(p => newPromise.progress = p); + this.onProgress(m => newPromise.update(m.size, m.total)); return newPromise; } onProgress(callback: ProgressCallback) { - this.listeners.push(callback); + (this.listeners ||= []).push(callback); return this; } then(res?: (v: T) => any, rej?: (err: any) => any): PromiseProgress { - const resp = super.then(res, rej); - return this.from(resp); + return this.from(super.then(res, rej)); } catch(rej?: (err: any) => any): PromiseProgress {