ng-http-caching
Version:
Cache for HTTP requests in Angular application.
721 lines (709 loc) • 26.5 kB
JavaScript
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