Compare commits

..
1 Commits
Author SHA1 Message Date
ztimson 01e799274a Added download with progress
Build / Publish Docs (push) Successful in 43s
Build / Build NPM Project (push) Successful in 43s
Build / Tag Version (push) Successful in 22s
2026-10-05 14:47:43 -04:00
3 changed files with 83 additions and 29 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.30.8", "version": "0.30.9",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",
+39 -3
View File
@@ -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) * Works in both browser (with progress) and Node.js (fallback without progress)
* *
* @param {{url: string, files: File[], headers?: {[p: string]: string}, withCredentials?: boolean}} options * @param {{url: string, files: File[], headers?: {[p: string]: string}, withCredentials?: boolean}} options
* @return {PromiseProgress<T>} Promise of request with `onProgress` callback * @return {PromiseProgress<T>} Promise of request with `onProgress` callback & meta getters
*/ */
export function uploadWithProgress<T>(options: { export function uploadWithProgress<T>(options: {
url: string; url: string;
@@ -102,7 +102,7 @@ export function uploadWithProgress<T>(options: {
options.files.forEach(f => formData.append('files', f)); options.files.forEach(f => formData.append('files', f));
xhr.withCredentials = !!options.withCredentials; 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(<T>JSONAttemptParse(xhr.responseText))); xhr.addEventListener('loadend', () => res(<T>JSONAttemptParse(xhr.responseText)));
xhr.addEventListener('error', () => rej(JSONAttemptParse(xhr.responseText))); xhr.addEventListener('error', () => rej(JSONAttemptParse(xhr.responseText)));
xhr.addEventListener('timeout', () => rej({error: 'Request timed out'})); xhr.addEventListener('timeout', () => rej({error: 'Request timed out'}));
@@ -131,3 +131,39 @@ export function uploadWithProgress<T>(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<Blob>} Promise of downloaded blob with `onProgress` callback & meta getters
*/
export function downloadWithProgress(options: {url: string; name?: string; headers?: {[key: string]: string}}): PromiseProgress<Blob> {
return new PromiseProgress<Blob>(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);
}
});
}
+43 -25
View File
@@ -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 * @example
* ```js * ```js
* const promise = new Promise((resolve, reject, progress) => { * const promise = new PromiseProgress((resolve, reject, progress) => {
* const max = 10; * const total = 100;
* for(let i = 0; i < max; i++) progress(i / max); * for(let i = 0; i <= total; i++) progress(i, total);
* resolve(1); * 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) * .then(console.log)
* .catch(console.error) * .catch(console.error)
* .finally(...); * .finally(...);
*
* ``` * ```
*/ */
export class PromiseProgress<T> extends Promise<T> { export class PromiseProgress<T> extends Promise<T> {
private listeners: ProgressCallback[] = []; 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 meta() { return this._meta; }
get progress() { return this._progress; } get start() { return this._meta.start; }
set progress(p: number) { get end() { return this._meta.end; }
if(p == this._progress) return; get elapsed() { return this._meta.elapsed; }
this._progress = p; get speed() { return this._meta.speed; }
this.listeners.forEach(l => l(p)); 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( super((resolve, reject) => executor(
(value: T) => resolve(value), (value: T) => { this._meta = {...this._meta, end: Date.now()}; resolve(value); },
(reason: any) => reject(reason), (reason: any) => { this._meta = {...this._meta, end: Date.now()}; reject(reason); },
(progress: number) => this.progress = progress, (size: number, total?: number) => this.update(size, total ?? this._meta.total),
)); ));
} }
static from<T>(promise: Promise<T>): PromiseProgress<T> { static from<T>(promise: Promise<T>): PromiseProgress<T> {
if(promise instanceof PromiseProgress) return promise; if(promise instanceof PromiseProgress) return promise;
return new PromiseProgress<T>((res, rej) => promise return new PromiseProgress<T>((res, rej) => promise.then(res).catch(rej));
.then((...args) => res(...args))
.catch((...args) => rej(...args)));
} }
private from(promise: Promise<T>): PromiseProgress<T> { private from(promise: Promise<T>): PromiseProgress<T> {
const newPromise = PromiseProgress.from(promise); const newPromise = PromiseProgress.from(promise);
this.onProgress(p => newPromise.progress = p); this.onProgress(m => newPromise.update(m.size, m.total));
return newPromise; return newPromise;
} }
onProgress(callback: ProgressCallback) { onProgress(callback: ProgressCallback) {
this.listeners.push(callback); (this.listeners ||= []).push(callback);
return this; return this;
} }
then(res?: (v: T) => any, rej?: (err: any) => any): PromiseProgress<any> { then(res?: (v: T) => any, rej?: (err: any) => any): PromiseProgress<any> {
const resp = super.then(res, rej); return this.from(super.then(res, rej));
return this.from(resp);
} }
catch(rej?: (err: any) => any): PromiseProgress<any> { catch(rej?: (err: any) => any): PromiseProgress<any> {