UNPKG

ng-http-caching

Version:

Cache for HTTP requests in Angular application.

721 lines (709 loc) 26.5 kB
import { HttpContextToken, HttpContext, HttpResponse, HTTP_INTERCEPTORS, HttpHeaders, HttpParams, HttpRequest } from '@angular/common/http'; import * as i0 from '@angular/core'; import { InjectionToken, VERSION, isDevMode, inject, Injectable, NgModule, makeEnvironmentProviders } from '@angular/core'; import { scheduled, of, asyncScheduler } from 'rxjs'; import { tap, finalize, shareReplay } from 'rxjs/operators'; class NgHttpCachingMemoryStorage extends Map { } const NG_HTTP_CACHING_CONTEXT = new HttpContextToken(() => ({})); const withNgHttpCachingContext = (value, context = new HttpContext()) => context.set(NG_HTTP_CACHING_CONTEXT, value); const checkCacheHeaders = (headers) => { // check Cache-Control const cacheControlHeader = headers.get('cache-control'); if (cacheControlHeader) { const cacheControl = cacheControlHeader.toLowerCase(); if (cacheControl.includes('no-store')) { return false; } else if (cacheControl.includes('no-cache')) { return false; } else { return true; } } // check Expires header Expires if response is without Cache-Control const expiresHeader = headers.get('expires'); if (expiresHeader) { const expires = Date.parse(expiresHeader); if (!isNaN(expires)) { return expires > Date.now(); } } return true; }; const NG_HTTP_CACHING_CONFIG = new InjectionToken('ng-http-caching.config'); var NgHttpCachingStrategy; (function (NgHttpCachingStrategy) { /** * All request are cacheable if HTTP method is into `allowedMethod` */ NgHttpCachingStrategy["ALLOW_ALL"] = "ALLOW_ALL"; /** * Only the request with `X-NG-HTTP-CACHING-ALLOW-CACHE` header are cacheable if HTTP method is into `allowedMethod` */ NgHttpCachingStrategy["DISALLOW_ALL"] = "DISALLOW_ALL"; })(NgHttpCachingStrategy || (NgHttpCachingStrategy = {})); var NgHttpCachingHeaders; (function (NgHttpCachingHeaders) { /** * Request is cacheable if HTTP method is into `allowedMethod` */ NgHttpCachingHeaders["ALLOW_CACHE"] = "X-NG-HTTP-CACHING-ALLOW-CACHE"; /** * Request isn't cacheable */ NgHttpCachingHeaders["DISALLOW_CACHE"] = "X-NG-HTTP-CACHING-DISALLOW-CACHE"; /** * Specific cache lifetime for the request */ NgHttpCachingHeaders["LIFETIME"] = "X-NG-HTTP-CACHING-LIFETIME"; /** * You can tag multiple request by adding this header with the same tag and * using `NgHttpCachingService.clearCacheByTag(tag: string)` for delete all the tagged request */ NgHttpCachingHeaders["TAG"] = "X-NG-HTTP-CACHING-TAG"; })(NgHttpCachingHeaders || (NgHttpCachingHeaders = {})); const NgHttpCachingHeadersList = Object.values(NgHttpCachingHeaders); const NG_HTTP_CACHING_SECOND_IN_MS = 1000; const NG_HTTP_CACHING_MINUTE_IN_MS = NG_HTTP_CACHING_SECOND_IN_MS * 60; const NG_HTTP_CACHING_HOUR_IN_MS = NG_HTTP_CACHING_MINUTE_IN_MS * 60; const NG_HTTP_CACHING_DAY_IN_MS = NG_HTTP_CACHING_HOUR_IN_MS * 24; const NG_HTTP_CACHING_WEEK_IN_MS = NG_HTTP_CACHING_DAY_IN_MS * 7; const NG_HTTP_CACHING_MONTH_IN_MS = NG_HTTP_CACHING_DAY_IN_MS * 30; const NG_HTTP_CACHING_YEAR_IN_MS = NG_HTTP_CACHING_DAY_IN_MS * 365; const NgHttpCachingConfigDefault = { store: new NgHttpCachingMemoryStorage(), lifetime: NG_HTTP_CACHING_HOUR_IN_MS, version: VERSION.major, allowedMethod: ['GET', 'HEAD'], cacheStrategy: NgHttpCachingStrategy.ALLOW_ALL, checkResponseHeaders: false }; class NgHttpCachingService { constructor() { this.queue = new Map(); this.gcLock = false; this.devMode = isDevMode(); const config = inject(NG_HTTP_CACHING_CONFIG, { optional: true }); if (config) { this.config = { ...NgHttpCachingConfigDefault, ...config }; } else { this.config = { ...NgHttpCachingConfigDefault }; } // start cache clean this.runGc(); } /** * Return the config */ getConfig() { return this.config; } /** * Return the queue map */ getQueue() { return this.queue; } /** * Return the cache store */ getStore() { return this.config.store; } /** * Return response from cache */ getFromCache(req) { const key = this.getKey(req); const cached = this.config.store.get(key); if (!cached) { return undefined; } if (this.isExpired(cached)) { this.clearCacheByKey(key); return undefined; } return this.deepFreeze(cached.response); } /** * Add response to cache */ addToCache(req, res) { const entry = { url: req.urlWithParams, response: res, request: req, addedTime: Date.now(), version: this.config.version, }; if (this.isValid(entry)) { const key = this.getKey(req); this.config.store.set(key, entry); return true; } return false; } /** * Delete response from cache */ deleteFromCache(req) { const key = this.getKey(req); return this.clearCacheByKey(key); } /** * Clear the cache */ clearCache() { this.config.store.clear(); } /** * Clear the cache by key */ clearCacheByKey(key) { return this.config.store.delete(key); } /** * Clear the cache by keys */ clearCacheByKeys(keys) { let counter = 0; if (keys) { for (const key of keys) { if (this.clearCacheByKey(key)) { counter++; } } } return counter; } /** * Clear the cache by regex */ clearCacheByRegex(regex) { const keys = []; this.config.store.forEach((_, key) => { if (regex.test(key)) { keys.push(key); } }); return this.clearCacheByKeys(keys); } /** * Clear the cache by TAG */ clearCacheByTag(tag) { const keys = []; this.config.store.forEach((entry, key) => { const tagHeader = entry.request.headers.get(NgHttpCachingHeaders.TAG); if (tagHeader && tagHeader.split(',').includes(tag)) { keys.push(key); } }); return this.clearCacheByKeys(keys); } /** * Run garbage collector (delete expired cache entry) */ runGc() { if (this.gcLock) { return false; } this.gcLock = true; const keys = []; this.config.store.forEach((entry, key) => { if (this.isExpired(entry)) { keys.push(key); } }); this.clearCacheByKeys(keys); this.gcLock = false; return true; } /** * Return true if cache entry is expired */ isExpired(entry) { // if user provide custom method, use it const context = entry.request.context.get(NG_HTTP_CACHING_CONTEXT); if (typeof context?.isExpired === 'function') { const result = context.isExpired(entry); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // if user provide custom method, use it if (typeof this.config.isExpired === 'function') { const result = this.config.isExpired(entry); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // if version change, always expire if (this.config.version !== entry.version) { return true; } // config/default lifetime let lifetime = this.config.lifetime; // request has own lifetime const headerLifetime = entry.request.headers.get(NgHttpCachingHeaders.LIFETIME); if (headerLifetime) { lifetime = +headerLifetime; } // never expire if 0 if (lifetime === 0) { return false; } // wrong lifetime if (lifetime < 0 || isNaN(lifetime)) { throw new Error('lifetime must be greater than or equal 0'); } return entry.addedTime + lifetime < Date.now(); } /** * Return true if cache entry is valid for store in the cache * Default behaviour is whether the status code falls in the 2xx range and response headers cache-control and expires allow cache. */ isValid(entry) { const context = entry.request.context.get(NG_HTTP_CACHING_CONTEXT); // if user provide custom method, use it if (typeof context.isValid === 'function') { const result = context.isValid(entry); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // if user provide custom method, use it if (typeof this.config.isValid === 'function') { const result = this.config.isValid(entry); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // different version if (this.config.version !== entry.version) { return false; } let fromHeader = true; if (this.config.checkResponseHeaders) { // check if response headers allow cache fromHeader = checkCacheHeaders(entry.response.headers); } return entry.response.ok && fromHeader; } /** * Return true if the request is cacheable */ isCacheable(req) { const context = req.context.get(NG_HTTP_CACHING_CONTEXT); // if user provide custom method, use it if (typeof context?.isCacheable === 'function') { const result = context.isCacheable(req); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // if user provide custom method, use it if (typeof this.config.isCacheable === 'function') { const result = this.config.isCacheable(req); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // request has disallow cache header if (req.headers.has(NgHttpCachingHeaders.DISALLOW_CACHE)) { return false; } // strategy is disallow all... if (this.config.cacheStrategy === NgHttpCachingStrategy.DISALLOW_ALL) { // request isn't allowed if come without allow header if (!req.headers.has(NgHttpCachingHeaders.ALLOW_CACHE)) { return false; } } // if allowed method is only ALL, allow all http methods if (this.config.allowedMethod.length === 1) { if (this.config.allowedMethod[0] === 'ALL') { return true; } } // request is allowed if method is in allowedMethod return this.config.allowedMethod.indexOf(req.method) !== -1; } /** * Return the cache key. * Default key is http method plus url with query parameters, eg.: * `GET@https://github.com/nigrosimone/ng-http-caching` */ getKey(req) { // if user provide custom method, use it const context = req.context.get(NG_HTTP_CACHING_CONTEXT); if (typeof context.getKey === 'function') { const result = context.getKey(req); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // if user provide custom method, use it if (typeof this.config.getKey === 'function') { const result = this.config.getKey(req); // if result is undefined, normal behaviour is provided if (result !== undefined) { return result; } } // default key is req.method plus url with query parameters return req.method + '@' + req.urlWithParams; } /** * Return observable from cache */ getFromQueue(req) { const key = this.getKey(req); const cached = this.queue.get(key); if (!cached) { return undefined; } return cached; } /** * Add observable to cache */ addToQueue(req, obs) { const key = this.getKey(req); this.queue.set(key, obs); } /** * Delete observable from cache */ deleteFromQueue(req) { const key = this.getKey(req); return this.queue.delete(key); } /** * Recursively Object.freeze simple Javascript structures consisting of plain objects, arrays, and primitives. * Make the data immutable. * @returns immutable object */ deepFreeze(object) { // No freezing in production (for better performance). if (!this.devMode || !object || typeof object !== 'object') { return object; } // When already frozen, we assume its children are frozen (for better performance). // This should be true if you always use `deepFreeze` to freeze objects. // // Note that Object.isFrozen will also return `true` for primitives (numbers, // strings, booleans, undefined, null), so there is no need to check for // those explicitly. if (Object.isFrozen(object)) { return object; } // At this point we know that we're dealing with either an array or plain object, so // just freeze it and recurse on its values. Object.freeze(object); Object.keys(object).forEach(key => this.deepFreeze(object[key])); return object; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingService, decorators: [{ type: Injectable }], ctorParameters: () => [] }); class NgHttpCachingInterceptorService { constructor() { this.cacheService = inject(NgHttpCachingService); } intercept(req, next) { // run garbage collector this.cacheService.runGc(); // Don't cache if it's not cacheable if (!this.cacheService.isCacheable(req)) { return this.sendRequest(req, next); } // Checked if there is pending response for this request const cachedObservable = this.cacheService.getFromQueue(req); if (cachedObservable) { // console.log('cachedObservable',cachedObservable); return cachedObservable; } // Checked if there is cached response for this request const cachedResponse = this.cacheService.getFromCache(req); if (cachedResponse) { // console.log('cachedResponse'); return scheduled(of(cachedResponse.clone()), asyncScheduler); } // If the request of going through for first time // then let the request proceed and cache the response // console.log('sendRequest', req); const shared = this.sendRequest(req, next).pipe(tap(event => { if (event instanceof HttpResponse) { this.cacheService.addToCache(req, event.clone()); } }), finalize(() => { // delete pending request this.cacheService.deleteFromQueue(req); }), shareReplay()); // add pending request to queue for cache parallel request this.cacheService.addToQueue(req, shared); return shared; } /** * Send http request (next handler) */ sendRequest(req, next) { let cloned = req.clone(); // trim custom headers before send request NgHttpCachingHeadersList.forEach(ngHttpCachingHeaders => { if (cloned.headers.has(ngHttpCachingHeaders)) { cloned = cloned.clone({ headers: cloned.headers.delete(ngHttpCachingHeaders) }); } }); return next.handle(cloned); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingInterceptorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingInterceptorService }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingInterceptorService, decorators: [{ type: Injectable }] }); class NgHttpCachingModule { static forRoot(ngHttpCachingConfig) { return { ngModule: NgHttpCachingModule, providers: [ { provide: NG_HTTP_CACHING_CONFIG, useValue: ngHttpCachingConfig, }, ], }; } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule }); } static { this.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingModule }); } static { this.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingModule, providers: [ NgHttpCachingService, NgHttpCachingInterceptorService, { provide: HTTP_INTERCEPTORS, useClass: NgHttpCachingInterceptorService, multi: true, }, ] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.0.1", ngImport: i0, type: NgHttpCachingModule, decorators: [{ type: NgModule, args: [{ providers: [ NgHttpCachingService, NgHttpCachingInterceptorService, { provide: HTTP_INTERCEPTORS, useClass: NgHttpCachingInterceptorService, multi: true, }, ] }] }] }); function provideNgHttpCaching(ngHttpCachingConfig) { const providers = []; if (ngHttpCachingConfig) { providers.push(makeEnvironmentProviders([{ provide: NG_HTTP_CACHING_CONFIG, useValue: ngHttpCachingConfig, }])); } providers.push(makeEnvironmentProviders([ NgHttpCachingService, { provide: HTTP_INTERCEPTORS, useClass: NgHttpCachingInterceptorService, multi: true, }, NgHttpCachingInterceptorService ])); return providers; } const KEY_PREFIX = 'NgHttpCaching::'; const serializeRequest = (req) => { const request = req.clone(); // Make a clone, useful for doing destructive things return JSON.stringify({ headers: Object.fromEntries(// Just a helper to make this into an object, not really required but makes the output nicer request.headers.keys().map(// Get all of the headers (key) => [key, request.headers.getAll(key)] // Get all of the corresponding values for the headers )), method: request.method, // The Request Method, e.g. GET, POST, DELETE url: request.url, // The URL params: Object.fromEntries(// Just a helper to make this into an object, not really required but makes the output nicer request.headers.keys().map(// Get all of the headers (key) => [key, request.headers.getAll(key)] // Get all of the corresponding values for the headers )), // The request parameters withCredentials: request.withCredentials, // Whether credentials are being sent responseType: request.responseType, // The response type body: request.serializeBody() // Serialize the body, all well and good since we are working on a clone }); }; const serializeResponse = (res) => { const response = res.clone(); return JSON.stringify({ headers: Object.fromEntries(// Just a helper to make this into an object, not really required but makes the output nicer response.headers.keys().map(// Get all of the headers (key) => [key, response.headers.getAll(key)] // Get all of the corresponding values for the headers )), status: response.status, statusText: response.statusText, url: response.url, body: response.body // Serialize the body, all well and good since we are working on a clone }); }; const deserializeRequest = (req) => { const request = JSON.parse(req); const headers = new HttpHeaders(request.headers); const params = new HttpParams(); // Probably some way to make this a one-liner, but alas, there are no good docs for (const parameter in request.params) { request.params[parameter].forEach((paramValue) => params.append(parameter, paramValue)); } return new HttpRequest(request.method, request.url, request.body, { headers, params, responseType: request.responseType, withCredentials: request.withCredentials }); }; const deserializeResponse = (res) => { const response = JSON.parse(res); return new HttpResponse({ url: response.url, headers: new HttpHeaders(response.headers), body: response.body, status: response.status, statusText: response.statusText, }); }; class NgHttpCachingBrowserStorage { constructor(storage) { this.storage = storage; } get size() { let count = 0; for (let i = 0, e = this.storage.length; i < e; i++) { const key = this.storage.key(i); if (key && key.startsWith(KEY_PREFIX)) { count++; } } return count; } clear() { for (let i = this.storage.length; i >= 0; i--) { const key = this.storage.key(i); if (key && key.startsWith(KEY_PREFIX)) { this.storage.removeItem(key); } } } delete(key) { if (!key) { return false; } if (!key.startsWith(KEY_PREFIX)) { key = KEY_PREFIX + key; } this.storage.removeItem(key); return true; } forEach(callbackfn) { // iterate this.storage for (let i = 0, e = this.storage.length; i < e; i++) { const keyWithPrefix = this.storage.key(i); if (keyWithPrefix && keyWithPrefix.startsWith(KEY_PREFIX)) { const value = this.get(keyWithPrefix); if (value) { const keyWithoutPrefix = keyWithPrefix.substring(KEY_PREFIX.length); callbackfn(value, keyWithoutPrefix); } } } } get(key) { if (!key) { return undefined; } if (!key.startsWith(KEY_PREFIX)) { key = KEY_PREFIX + key; } const item = this.storage.getItem(key); if (item) { const parsedItem = JSON.parse(item); return this.deserialize(parsedItem); } return undefined; } has(key) { if (!key) { return false; } if (!key.startsWith(KEY_PREFIX)) { key = KEY_PREFIX + key; } return !!this.storage.getItem(key); } set(key, value) { if (!key) { return; } if (!key.startsWith(KEY_PREFIX)) { key = KEY_PREFIX + key; } const unParsedItem = this.serialize(value); this.storage.setItem(key, JSON.stringify(unParsedItem)); } serialize(value) { return { url: value.url, response: serializeResponse(value.response), request: serializeRequest(value.request), addedTime: value.addedTime, version: value.version }; } deserialize(value) { return { url: value.url, response: deserializeResponse(value.response), request: deserializeRequest(value.request), addedTime: value.addedTime, version: value.version }; } } class NgHttpCachingLocalStorage extends NgHttpCachingBrowserStorage { constructor() { super(localStorage); } } const withNgHttpCachingLocalStorage = () => new NgHttpCachingLocalStorage(); class NgHttpCachingSessionStorage extends NgHttpCachingBrowserStorage { constructor() { super(sessionStorage); } } const withNgHttpCachingSessionStorage = () => new NgHttpCachingSessionStorage(); /* * Public API Surface of ng-http-caching */ /** * Generated bundle index. Do not edit. */ export { NG_HTTP_CACHING_CONFIG, NG_HTTP_CACHING_CONTEXT, NG_HTTP_CACHING_DAY_IN_MS, NG_HTTP_CACHING_HOUR_IN_MS, NG_HTTP_CACHING_MINUTE_IN_MS, NG_HTTP_CACHING_MONTH_IN_MS, NG_HTTP_CACHING_SECOND_IN_MS, NG_HTTP_CACHING_WEEK_IN_MS, NG_HTTP_CACHING_YEAR_IN_MS, NgHttpCachingBrowserStorage, NgHttpCachingConfigDefault, NgHttpCachingHeaders, NgHttpCachingHeadersList, NgHttpCachingInterceptorService, NgHttpCachingLocalStorage, NgHttpCachingMemoryStorage, NgHttpCachingModule, NgHttpCachingService, NgHttpCachingSessionStorage, NgHttpCachingStrategy, checkCacheHeaders, deserializeRequest, deserializeResponse, provideNgHttpCaching, serializeRequest, serializeResponse, withNgHttpCachingContext, withNgHttpCachingLocalStorage, withNgHttpCachingSessionStorage }; //# sourceMappingURL=ng-http-caching.mjs.map