@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
JavaScript
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