add caching module
This commit is contained in:
parent
8549e57606
commit
97867a6f38
@ -16,3 +16,4 @@ export * from './lib/error';
|
|||||||
export * from './lib/search';
|
export * from './lib/search';
|
||||||
export * from './lib/empty-states';
|
export * from './lib/empty-states';
|
||||||
export * from './lib/scrollbar';
|
export * from './lib/scrollbar';
|
||||||
|
export * from './lib/caching';
|
||||||
|
|||||||
263
src/lib/caching/cache-api.service.ts
Normal file
263
src/lib/caching/cache-api.service.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
import { Inject, Injectable } from '@angular/core';
|
||||||
|
import { HttpEvent, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||||
|
import { from, Observable, of, throwError } from 'rxjs';
|
||||||
|
import { map, mergeMap } from 'rxjs/operators';
|
||||||
|
import { APP_LEVEL_CACHE, DYNAMIC_CACHES } from './cache-utils';
|
||||||
|
import { DynamicCache, DynamicCaches } from './dynamic-cache';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CacheApiService {
|
||||||
|
constructor(@Inject(DYNAMIC_CACHES) private readonly _dynamicCaches: DynamicCaches) {
|
||||||
|
this.checkCachesExpiration().then();
|
||||||
|
}
|
||||||
|
|
||||||
|
get cachesAvailable(): boolean {
|
||||||
|
return !!window.caches;
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheRequest(request: HttpRequest<any>, httpResponse: HttpResponse<any>) {
|
||||||
|
if (httpResponse.status >= 300 || httpResponse.status < 200) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this._buildUrl(request);
|
||||||
|
for (const dynCache of this._dynamicCaches) {
|
||||||
|
for (const cacheUrl of dynCache.urls) {
|
||||||
|
if (url.indexOf(cacheUrl) >= 0) {
|
||||||
|
return caches.open(dynCache.name).then(cache => this._handleFetchResponse(httpResponse, dynCache, cache, url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async wipeCaches(logoutDependant = false) {
|
||||||
|
await caches.delete(APP_LEVEL_CACHE);
|
||||||
|
|
||||||
|
for (const cache of this._dynamicCaches) {
|
||||||
|
if (!logoutDependant) {
|
||||||
|
await caches.delete(cache.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoutDependant && cache.clearOnLogout) {
|
||||||
|
await caches.delete(cache.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedRequest(request: HttpRequest<any>): Observable<HttpEvent<any>> {
|
||||||
|
const url = this._buildUrl(request);
|
||||||
|
|
||||||
|
return from(caches.match(url)).pipe(
|
||||||
|
mergeMap(response => {
|
||||||
|
if (response) {
|
||||||
|
const expires = response.headers.get('_expires');
|
||||||
|
if (expires) {
|
||||||
|
// if not expired, return, else override
|
||||||
|
if (parseInt(expires, 10) > new Date().getTime()) {
|
||||||
|
// console.log('[CACHE-API] Returning from cache: ', url);
|
||||||
|
return this._toHttpResponse(response);
|
||||||
|
}
|
||||||
|
// console.log('[CACHE-API] cache expired: ', url);
|
||||||
|
} else {
|
||||||
|
// console.log('[CACHE-API] Returning from cache: ', url);
|
||||||
|
return this._toHttpResponse(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return throwError(() => new Error('Request not Cached'));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cacheValue(name: string, valueReference: any, ttl = 3600): Promise<any> {
|
||||||
|
if (!this.cachesAvailable) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await caches.open(APP_LEVEL_CACHE);
|
||||||
|
const string = JSON.stringify(valueReference);
|
||||||
|
const expires = new Date().getTime() + ttl * 1000;
|
||||||
|
const response = new Response(string, {
|
||||||
|
headers: {
|
||||||
|
_expires: `${expires}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const request = new Request(name);
|
||||||
|
// console.log('should cache', valueReference, string, response);
|
||||||
|
return cache.put(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCachedValue(name: string): Promise<any> {
|
||||||
|
if (!this.cachesAvailable) {
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = await caches.open(APP_LEVEL_CACHE);
|
||||||
|
const result = await cache.match(name);
|
||||||
|
if (!result) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = result.headers.get('_expires') ?? '0';
|
||||||
|
try {
|
||||||
|
if (parseInt(expires, 10) > new Date().getTime()) {
|
||||||
|
return result.json();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildUrl(request: HttpRequest<any>) {
|
||||||
|
if (request.method === 'GET') {
|
||||||
|
return request.urlWithParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const body = request.body;
|
||||||
|
let hash: string;
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
hash = JSON.stringify(body.sort());
|
||||||
|
} else {
|
||||||
|
hash = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const separator = request.urlWithParams.indexOf('?') > 0 ? '&' : '?';
|
||||||
|
return request.urlWithParams + separator + window.btoa(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
isCachable(event: HttpRequest<any>) {
|
||||||
|
// only do shit for post and get
|
||||||
|
|
||||||
|
if (this.cachesAvailable && (event.method === 'GET' || event.method === 'POST')) {
|
||||||
|
// quick check if it has the potential of caching
|
||||||
|
const preliminaryUrl = event.url;
|
||||||
|
let tryCache = false;
|
||||||
|
|
||||||
|
for (const cache of this._dynamicCaches) {
|
||||||
|
if (cache.methods.indexOf(event.method) >= 0) {
|
||||||
|
for (const url of cache.urls) {
|
||||||
|
if (preliminaryUrl.indexOf(url) >= 0) {
|
||||||
|
tryCache = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tryCache;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkCachesExpiration() {
|
||||||
|
if (this.cachesAvailable) {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
for (const dynCache of this._dynamicCaches) {
|
||||||
|
await this._handleCacheExpiration(dynCache, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toHttpResponse(response: Response): Observable<HttpResponse<unknown>> {
|
||||||
|
const cloned = response.clone();
|
||||||
|
const contentType = response.headers.get('content-type')?.toLowerCase();
|
||||||
|
|
||||||
|
let obs: Observable<unknown> = of(response);
|
||||||
|
if (contentType) {
|
||||||
|
if (contentType.indexOf('application/json') >= 0) {
|
||||||
|
obs = from(cloned.json());
|
||||||
|
}
|
||||||
|
if (contentType.indexOf('text/') >= 0) {
|
||||||
|
obs = from(cloned.text());
|
||||||
|
}
|
||||||
|
if (contentType.indexOf('image/') >= 0) {
|
||||||
|
if (contentType.indexOf('image/svg') >= 0) {
|
||||||
|
obs = from(cloned.text());
|
||||||
|
} else {
|
||||||
|
obs = from(cloned.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contentType.indexOf('application/pdf') >= 0) {
|
||||||
|
obs = from(cloned.blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('[CACHE-API] content type', contentType, response.url);
|
||||||
|
return obs?.pipe(
|
||||||
|
map(
|
||||||
|
body =>
|
||||||
|
// console.log('[CACHE-API] BODY', body);
|
||||||
|
new HttpResponse({
|
||||||
|
body,
|
||||||
|
status: cloned.status,
|
||||||
|
statusText: cloned.statusText,
|
||||||
|
url: cloned.url,
|
||||||
|
headers: this._toHttpHeaders(cloned.headers),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleFetchResponse(httpResponse: HttpResponse<any>, dynCache: DynamicCache, cache: Cache, url: string) {
|
||||||
|
const expires = new Date().getTime() + dynCache.maxAge * 1000;
|
||||||
|
|
||||||
|
const cachedResponseFields: ResponseInit = {
|
||||||
|
status: httpResponse.status,
|
||||||
|
statusText: httpResponse.statusText,
|
||||||
|
headers: {
|
||||||
|
_expires: '0',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
httpResponse.headers.keys().forEach(key => {
|
||||||
|
(cachedResponseFields.headers as Record<string, string>)[key] = httpResponse.headers.get(key) ?? '';
|
||||||
|
});
|
||||||
|
(cachedResponseFields.headers as Record<string, string>)['_expires'] = expires.toString();
|
||||||
|
|
||||||
|
let body;
|
||||||
|
const contentType = (cachedResponseFields.headers as Record<string, string>)['content-type'];
|
||||||
|
if (contentType?.indexOf('application/json') >= 0) {
|
||||||
|
body = JSON.stringify(httpResponse.body);
|
||||||
|
} else {
|
||||||
|
body = httpResponse.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.put(url, new Response(body, cachedResponseFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleCacheExpiration(dynCache: DynamicCache, now: number) {
|
||||||
|
const cache = await caches.open(dynCache.name);
|
||||||
|
let keys = await cache.keys();
|
||||||
|
// removed expired;
|
||||||
|
for (const key of keys) {
|
||||||
|
// console.log('[CACHE-API] checking cache key: ', key);
|
||||||
|
const response = await cache.match(key);
|
||||||
|
const expires = response?.headers.get('_expires') ?? '0';
|
||||||
|
try {
|
||||||
|
if (parseInt(expires, 10) < now) {
|
||||||
|
await cache.delete(key);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = await cache.keys();
|
||||||
|
if (keys.length > dynCache.maxSize) {
|
||||||
|
const keysToRemove = keys.slice(0, keys.length - dynCache.maxSize);
|
||||||
|
// console.log('[CACHE-API] cache too large - removing keys: ', keysToRemove);
|
||||||
|
for (let i = 0; i < keysToRemove.length; i++) {
|
||||||
|
const key = keys[i];
|
||||||
|
await cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toHttpHeaders(headers: Headers): HttpHeaders {
|
||||||
|
let httpHeaders = new HttpHeaders();
|
||||||
|
headers.forEach((value, key) => {
|
||||||
|
httpHeaders = httpHeaders.append(key, value);
|
||||||
|
});
|
||||||
|
return httpHeaders;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/lib/caching/cache-utils.ts
Normal file
22
src/lib/caching/cache-utils.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
import { DynamicCaches } from './dynamic-cache';
|
||||||
|
|
||||||
|
export const APP_LEVEL_CACHE = 'app-level-cache';
|
||||||
|
|
||||||
|
export const DYNAMIC_CACHES = new InjectionToken<DynamicCaches>('dynamic-caches');
|
||||||
|
|
||||||
|
export async function wipeAllCaches() {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
for (const cache of keys) {
|
||||||
|
await caches.delete(cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wipeCache(cacheName: string) {
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wipeCacheEntry(cacheName: string, entry: string) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
return cache.delete(entry, { ignoreSearch: false });
|
||||||
|
}
|
||||||
27
src/lib/caching/caching.module.ts
Normal file
27
src/lib/caching/caching.module.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { ModuleWithProviders, NgModule } from '@angular/core';
|
||||||
|
import { DynamicCaches } from './dynamic-cache';
|
||||||
|
import { DYNAMIC_CACHES } from './cache-utils';
|
||||||
|
import { CacheApiService } from './cache-api.service';
|
||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { HttpCacheInterceptor } from './http-cache-interceptor';
|
||||||
|
|
||||||
|
@NgModule({})
|
||||||
|
export class CachingModule {
|
||||||
|
static forRoot(dynamicCaches: DynamicCaches): ModuleWithProviders<CachingModule> {
|
||||||
|
return {
|
||||||
|
ngModule: CachingModule,
|
||||||
|
providers: [
|
||||||
|
CacheApiService,
|
||||||
|
{
|
||||||
|
provide: HTTP_INTERCEPTORS,
|
||||||
|
multi: true,
|
||||||
|
useClass: HttpCacheInterceptor,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DYNAMIC_CACHES,
|
||||||
|
useValue: dynamicCaches,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/lib/caching/dynamic-cache.ts
Normal file
12
src/lib/caching/dynamic-cache.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { List } from '../utils';
|
||||||
|
|
||||||
|
export interface DynamicCache {
|
||||||
|
readonly urls: List;
|
||||||
|
readonly methods: List;
|
||||||
|
readonly name: string;
|
||||||
|
readonly maxAge: number;
|
||||||
|
readonly maxSize: number;
|
||||||
|
readonly clearOnLogout?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DynamicCaches = List<DynamicCache>;
|
||||||
41
src/lib/caching/http-cache-interceptor.ts
Normal file
41
src/lib/caching/http-cache-interceptor.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http';
|
||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { CacheApiService } from './cache-api.service';
|
||||||
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HttpCacheInterceptor implements HttpInterceptor {
|
||||||
|
constructor(private readonly _cacheApiService: CacheApiService) {}
|
||||||
|
|
||||||
|
intercept(req: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||||
|
if (!this._cacheApiService.isCachable(req)) {
|
||||||
|
return next.handle(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._cacheApiService.getCachedRequest(req).pipe(
|
||||||
|
tap(ok => {
|
||||||
|
console.log('[CACHE-API] got from cache', ok);
|
||||||
|
}),
|
||||||
|
catchError(cacheError => {
|
||||||
|
console.log('[CACHE-API] Cache fetch error', cacheError, req.url);
|
||||||
|
return this.sendRequest(req, next);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server response observable by sending request to `next()`.
|
||||||
|
* Will add the response to the cache on the way out.
|
||||||
|
*/
|
||||||
|
sendRequest(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
return next.handle(request).pipe(
|
||||||
|
tap(async event => {
|
||||||
|
// There may be other events besides the response.
|
||||||
|
if (event instanceof HttpResponse) {
|
||||||
|
await this._cacheApiService.cacheRequest(request, event);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/lib/caching/index.ts
Normal file
5
src/lib/caching/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from './dynamic-cache';
|
||||||
|
export * from './cache-api.service';
|
||||||
|
export * from './cache-utils';
|
||||||
|
export * from './http-cache-interceptor';
|
||||||
|
export * from './caching.module';
|
||||||
Loading…
x
Reference in New Issue
Block a user