Compare commits

...

2 Commits

Author SHA1 Message Date
d29d0d5121 PathEvent caching fix
All checks were successful
Build / Publish Docs (push) Successful in 1m47s
Build / Build NPM Project (push) Successful in 34s
Build / Tag Version (push) Successful in 9s
2026-07-14 20:21:05 -04:00
5b3bbdf02c Added node support for uploadWithProgress
All checks were successful
Build / Publish Docs (push) Successful in 54s
Build / Build NPM Project (push) Successful in 38s
Build / Tag Version (push) Successful in 9s
2026-07-13 22:16:58 -04:00
3 changed files with 92 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@ztimson/utils", "name": "@ztimson/utils",
"version": "0.29.5", "version": "0.29.7",
"description": "Utility library", "description": "Utility library",
"author": "Zak Timson", "author": "Zak Timson",
"license": "MIT", "license": "MIT",

View File

@@ -83,6 +83,7 @@ export function timestampFilename(name?: string, date: Date | number | string =
/** /**
* Upload file to URL with progress callback using PromiseProgress * Upload file to URL with progress callback using 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 * @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
@@ -93,19 +94,40 @@ export function uploadWithProgress<T>(options: {
headers?: {[key: string]: string}; headers?: {[key: string]: string};
withCredentials?: boolean; withCredentials?: boolean;
}): PromiseProgress<T> { }): PromiseProgress<T> {
return new PromiseProgress<T>((res, rej, prog) => { // Browser environment - use XMLHttpRequest for progress
const xhr = new XMLHttpRequest(); if (typeof XMLHttpRequest !== 'undefined') {
const formData = new FormData(); return new PromiseProgress<T>((res, rej, prog) => {
options.files.forEach(f => formData.append('file', f)); const xhr = new XMLHttpRequest();
const formData = new FormData();
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'}));
xhr.open('POST', options.url); xhr.open('POST', options.url);
Object.entries(options.headers || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value)); Object.entries(options.headers || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value));
xhr.send(formData); xhr.send(formData);
});
}
// Node.js environment - fallback to fetch without progress
return new PromiseProgress<T>(async (res, rej) => {
try {
const formData = new FormData();
options.files.forEach(f => formData.append('files', f));
const response = await fetch(options.url, {method: 'POST', headers: options.headers || {}, body: formData});
if(!response.ok) {
const error = await response.text();
rej(JSONAttemptParse(error));
} else {
const result = await response.text();
res(<T>JSONAttemptParse(result));
}
} catch (error) {
rej(error);
}
}); });
} }

View File

@@ -76,8 +76,18 @@ export class PathEvent {
/** Whether this path contains glob patterns */ /** Whether this path contains glob patterns */
hasGlob!: boolean; hasGlob!: boolean;
/** Internal cache for PathEvent instances to avoid redundant parsing */ /** Internal cache for parsed path data (plain objects, not instances) */
private static pathEventCache: Map<string, PathEvent> = new Map(); private static pathEventCache: Map<string, {
module: string;
fullPath: string;
dir: string;
path: string;
name: string;
methods: Method[];
hasGlob: boolean;
}> = new Map();
/** Max size for path cache before LRU eviction */
private static readonly MAX_PATH_CACHE_SIZE = 1000;
/** Cache for compiled permissions (path + required permissions → result) */ /** Cache for compiled permissions (path + required permissions → result) */
private static permissionCache: Map<string, PathEvent> = new Map(); private static permissionCache: Map<string, PathEvent> = new Map();
/** Max size for permission cache before LRU eviction */ /** Max size for permission cache before LRU eviction */
@@ -111,8 +121,20 @@ export class PathEvent {
return; return;
} }
// Check cache and reconstruct from plain object
if(PathEvent.pathEventCache.has(e)) { if(PathEvent.pathEventCache.has(e)) {
Object.assign(this, PathEvent.pathEventCache.get(e)!); const cached = PathEvent.pathEventCache.get(e)!;
// Move to end (LRU - most recently used)
PathEvent.pathEventCache.delete(e);
PathEvent.pathEventCache.set(e, cached);
this.module = cached.module;
this.fullPath = cached.fullPath;
this.dir = cached.dir;
this.path = cached.path;
this.name = cached.name;
this.methods = new ASet(cached.methods);
this.hasGlob = cached.hasGlob;
return; return;
} }
@@ -128,7 +150,22 @@ export class PathEvent {
this.name = ''; this.name = '';
this.methods = new ASet<Method>(p === '*' ? ['*'] : <any>method.split('')); this.methods = new ASet<Method>(p === '*' ? ['*'] : <any>method.split(''));
this.hasGlob = true; this.hasGlob = true;
PathEvent.pathEventCache.set(e, this);
// LRU eviction
if(PathEvent.pathEventCache.size >= PathEvent.MAX_PATH_CACHE_SIZE) {
const firstKey = PathEvent.pathEventCache.keys().next().value;
if(firstKey) PathEvent.pathEventCache.delete(firstKey);
}
PathEvent.pathEventCache.set(e, {
module: this.module,
fullPath: this.fullPath,
dir: this.dir,
path: this.path,
name: this.name,
methods: [...this.methods],
hasGlob: this.hasGlob
});
return; return;
} }
@@ -140,7 +177,22 @@ export class PathEvent {
this.name = temp.pop() || ''; this.name = temp.pop() || '';
this.hasGlob = this.fullPath.includes('*'); this.hasGlob = this.fullPath.includes('*');
this.methods = new ASet(<any>method.split('')); this.methods = new ASet(<any>method.split(''));
PathEvent.pathEventCache.set(e, this);
// LRU eviction
if(PathEvent.pathEventCache.size >= PathEvent.MAX_PATH_CACHE_SIZE) {
const firstKey = PathEvent.pathEventCache.keys().next().value;
if(firstKey) PathEvent.pathEventCache.delete(firstKey);
}
PathEvent.pathEventCache.set(e, {
module: this.module,
fullPath: this.fullPath,
dir: this.dir,
path: this.path,
name: this.name,
methods: [...this.methods],
hasGlob: this.hasGlob
});
} }
/** /**