UNPKG

@hasan-akbari/advanced-http-client

Version:

Advanced Angular HttpClient with cache, inflight dedup, rate limit, debounce, queueing, batching, retry/backoff, timeout, logging.

293 lines (283 loc) 13.8 kB
import * as i0 from '@angular/core'; import { Injectable, Component } from '@angular/core'; import { defer, from, ReplaySubject, of, timer, throwError } from 'rxjs'; import { switchMap, finalize, catchError, timeout, retryWhen, scan, tap, shareReplay } from 'rxjs/operators'; import * as i1 from '@angular/common/http'; import { HttpHeaders, HttpParams } from '@angular/common/http'; class MemoryCache { constructor() { this.map = new Map(); } set(key, value, durationMs) { this.map.set(key, { value, expiresAt: Date.now() + durationMs }); } get(key) { const e = this.map.get(key); if (!e) return undefined; if (e.expiresAt <= Date.now()) { this.map.delete(key); return undefined; } return e.value; } delete(key) { this.map.delete(key); } clear() { this.map.clear(); } cleanup() { const now = Date.now(); for (const [k, e] of this.map.entries()) if (e.expiresAt <= now) this.map.delete(k); } keys() { return Array.from(this.map.keys()); } } function stableStringify(obj) { if (obj === null || obj === undefined) return String(obj); if (typeof obj !== 'object') return JSON.stringify(obj); if (Array.isArray(obj)) return `[${obj.map(stableStringify).join(',')}]`; const keys = Object.keys(obj).sort(); return `{${keys.map(k => `"${k}":${stableStringify(obj[k])}`).join(',')}}`; } class RequestQueue { constructor(concurrency = 4) { this.concurrency = concurrency; this.active = 0; this.waiting = []; } priorityToNumber(p = 'normal') { return p === 'high' ? 3 : p === 'normal' ? 2 : 1; } acquire(priority = 'normal') { if (this.active < this.concurrency) { this.active++; return Promise.resolve(); } return new Promise(resolve => { this.waiting.push({ pr: this.priorityToNumber(priority), resolve }); this.waiting.sort((a, b) => b.pr - a.pr); }); } release() { if (this.waiting.length > 0) { const next = this.waiting.shift(); next?.resolve(); } else { this.active = Math.max(0, this.active - 1); } } execute(factory, priority = 'normal', concurrency) { if (concurrency && concurrency !== this.concurrency) this.concurrency = concurrency; return defer(() => from(this.acquire(priority)).pipe(switchMap(() => factory().pipe(finalize(() => this.release()))))); } } class RequestBatcher { constructor() { this.buffers = new Map(); } enqueue(key, payload, options, perform, endpoint) { const buf = this.buffers.get(key) ?? { items: [], opts: { ...options } }; this.buffers.set(key, buf); const pKey = JSON.stringify(payload); const existing = buf.items.find(i => JSON.stringify(i.payload) === pKey); if (existing) return existing.sub.asObservable(); const sub = new ReplaySubject(1); buf.items.push({ payload, sub }); const size = options.size ?? 10; const intervalMs = options.intervalMs ?? 50; const flush = () => { const items = buf.items.splice(0, buf.items.length); clearTimeout(buf.timer); buf.timer = undefined; const payloads = items.map(i => i.payload); const combined = (options.combine ?? ((arr) => arr))(payloads); const ep = options.endpoint ?? endpoint; perform(combined, ep).pipe(catchError(err => { items.forEach(i => i.sub.error(err)); throw err; })).subscribe(resp => { items.forEach((i, idx) => { const selector = options.selector ?? ((r, _p, ix) => Array.isArray(r) ? r[ix] : r); try { i.sub.next(selector(resp, i.payload, idx)); i.sub.complete(); } catch (e) { i.sub.error(e); } }); }); }; const anyBuf = buf; if (buf.items.length >= size) flush(); else { if (anyBuf.timer) clearTimeout(anyBuf.timer); anyBuf.timer = setTimeout(flush, intervalMs); } return sub.asObservable(); } } function toHeaders(h) { if (!h) return undefined; return h instanceof HttpHeaders ? h : new HttpHeaders(h); } function toParams(p) { if (!p) return undefined; if (p instanceof HttpParams) return p; let hp = new HttpParams(); Object.entries(p).forEach(([k, v]) => { if (v === null || v === undefined) return; hp = hp.set(k, String(v)); }); return hp; } function backoffDelay(attempt, base, type, max) { let d = base; if (type === 'linear') d = base * attempt; else if (type === 'exponential') d = base * Math.pow(2, attempt - 1); else d = base * Math.pow(2, attempt - 1) + Math.floor(Math.random() * base); return max ? Math.min(d, max) : d; } class AdvancedHttpClientService { constructor(http) { this.http = http; this.cache = new MemoryCache(); this.inflight = new Map(); this.lastSent = new Map(); this.queue = new RequestQueue(4); this.batcher = new RequestBatcher(); this.cleanupHandle = setInterval(() => this.cache.cleanup(), 60000); } send(endpoint, payload, options = {}) { const method = options.method ?? 'GET'; const body = options.body ?? (method === 'GET' || method === 'HEAD' || method === 'OPTIONS' ? undefined : payload); const paramsObj = options.params ?? {}; const params = toParams(paramsObj); const headers = toHeaders(options.headers); const cacheKey = `${method} ${endpoint} :: ${stableStringify({ params: paramsObj, body })}`; const raw = !!options.raw; const inflightKey = !raw ? (options.batch?.enabled ? `${cacheKey} :: payload=${stableStringify(payload)}` : cacheKey) : undefined; if (options.cacheDurationMs && options.cacheDurationMs > 0) { const c = this.cache.get(cacheKey); if (c !== undefined) return of(c); } if (!raw) { const ex = this.inflight.get(inflightKey); if (ex) return ex; } const logEnabled = !!options.log?.enabled; const logLevel = options.log?.level ?? 'basic'; const log = logEnabled ? { key: inflightKey ?? cacheKey, method, endpoint, startedAt: Date.now() } : undefined; let stream = this.http.request(method, endpoint, { body, headers, params, observe: 'body', responseType: 'json' }); if (options.timeoutMs) { stream = stream.pipe(timeout(options.timeoutMs)); } if (options.retry?.attempts) { stream = stream.pipe(retryWhen(errs => errs.pipe(scan((acc, err) => { const attempts = options.retry?.attempts ?? 0; const should = options.retry?.shouldRetry?.(err) ?? true; if (!should || acc >= attempts) throw err; return acc + 1; }, 0), switchMap(attempt => timer(backoffDelay(attempt, options.retry?.baseDelayMs ?? 250, options.retry?.backoff ?? 'exponential', options.retry?.maxDelayMs)))))); } stream = stream.pipe(tap(res => { if (options.cacheDurationMs && options.cacheDurationMs > 0) this.cache.set(cacheKey, res, options.cacheDurationMs); if (logEnabled && logLevel !== 'none') { log.status = 'ok'; log.finishedAt = Date.now(); if (logLevel === 'verbose') log.meta = { size: JSON.stringify(res).length }; options.log?.sendToServer?.(log); if (options.debug) console.debug('[HTTP]', log); } }), catchError(err => { if (options.retry?.fallbackValue !== undefined) { const fv = typeof options.retry.fallbackValue === 'function' ? options.retry.fallbackValue() : options.retry.fallbackValue; return of(fv); } if (logEnabled && logLevel !== 'none') { log.status = 'error'; log.finishedAt = Date.now(); log.error = err; options.log?.sendToServer?.(log); if (options.debug) console.error('[HTTP]', log); } return throwError(() => err); }), finalize(() => { if (!raw && inflightKey) this.inflight.delete(inflightKey); if (!raw) this.lastSent.set(cacheKey, Date.now()); })); if (!raw) { stream = stream.pipe(shareReplay({ bufferSize: 1, refCount: true })); } const debounceMs = options.debounceMs ?? 0; const minIntervalMs = options.rateLimitMs ?? 0; const last = this.lastSent.get(cacheKey) ?? 0; const needDelay = minIntervalMs > 0 ? Math.max(0, minIntervalMs - (Date.now() - last)) : 0; const totalDelay = Math.max(debounceMs, needDelay); const exec$ = (options.queue?.enabled) ? this.queue.execute(() => stream, options.queue.priority ?? 'normal', options.queue.concurrency ?? (options.queue.mode === 'parallel' ? 4 : 1)) : stream; let scheduled$; if (totalDelay > 0) { scheduled$ = timer(totalDelay).pipe(switchMap(() => exec$)); } else { scheduled$ = exec$; } let out$; if (options.batch?.enabled && !raw) { const bKey = options.batch.key ?? `${method}:${endpoint}`; let batchStream = this.batcher.enqueue(bKey, payload, options.batch, (combined, ep) => { const epWithQuery = (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') && Array.isArray(combined) ? `${ep}?${combined.map((v) => `id=${encodeURIComponent(v)}`).join('&')}` : ep; const bodyForMethod = (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') ? undefined : combined; return this.http.request(method, epWithQuery, { body: bodyForMethod, headers, params, observe: 'body', responseType: 'json' }); }, endpoint); if (options.timeoutMs) { batchStream = batchStream.pipe(timeout(options.timeoutMs)); } out$ = batchStream.pipe(shareReplay({ bufferSize: 1, refCount: true })); } else { out$ = scheduled$; } if (!raw && inflightKey) { this.inflight.set(inflightKey, out$); } return out$; } get(endpoint, params, options = {}) { return this.send(endpoint, undefined, { ...options, method: 'GET', params }); } post(endpoint, body, options = {}) { return this.send(endpoint, body, { ...options, method: 'POST', body }); } put(endpoint, body, options = {}) { return this.send(endpoint, body, { ...options, method: 'PUT', body }); } patch(endpoint, body, options = {}) { return this.send(endpoint, body, { ...options, method: 'PATCH', body }); } delete(endpoint, body, options = {}) { return this.send(endpoint, body, { ...options, method: 'DELETE', body }); } head(endpoint, params, options = {}) { return this.send(endpoint, undefined, { ...options, method: 'HEAD', params }); } options(endpoint, params, options = {}) { return this.send(endpoint, undefined, { ...options, method: 'OPTIONS', params }); } clearCacheByKey(method, endpoint, paramsOrBody) { const key = `${method} ${endpoint} :: ${stableStringify(paramsOrBody ?? {})}`; this.cache.delete(key); } clearCacheByEndpoint(endpoint) { for (const k of this.cache.keys()) if (k.includes(` ${endpoint} :: `)) this.cache.delete(k); } clearAllCache() { this.cache.clear(); } ngOnDestroy() { clearInterval(this.cleanupHandle); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AdvancedHttpClientService, deps: [{ token: i1.HttpClient }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AdvancedHttpClientService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AdvancedHttpClientService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.HttpClient }] }); class AdvancedHttpClientComponent { static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AdvancedHttpClientComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: AdvancedHttpClientComponent, isStandalone: true, selector: "lib-advanced-http-client", ngImport: i0, template: ` <p> advanced-http-client works! </p> `, isInline: true, styles: [""] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: AdvancedHttpClientComponent, decorators: [{ type: Component, args: [{ selector: 'lib-advanced-http-client', standalone: true, imports: [], template: ` <p> advanced-http-client works! </p> ` }] }] }); /* * Public API Surface of advanced-http-client */ /** * Generated bundle index. Do not edit. */ export { AdvancedHttpClientComponent, AdvancedHttpClientService }; //# sourceMappingURL=hasan-akbari-advanced-http-client.mjs.map