common-ui/src/lib/caching/cache-api.service.ts
2023-03-02 12:48:22 +02:00

286 lines
9.8 KiB
TypeScript

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, wipeCache } 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 {
try {
return !!caches;
} catch (e) {
return false;
}
}
cacheRequest(request: HttpRequest<any>, httpResponse: HttpResponse<any>) {
if (httpResponse.status >= 300 || httpResponse.status < 200) {
return;
}
if (!this.cachesAvailable) {
return;
}
const url = this._buildUrl(request);
for (const dynCache of this._dynamicCaches) {
for (const cacheUrl of dynCache.urls) {
if (url.indexOf(cacheUrl) >= 0) {
// console.log('[CACHE-API] open cache: ', dynCache.name);
return caches.open(dynCache.name).then(cache => this._handleFetchResponse(httpResponse, dynCache, cache, url));
}
}
}
return;
}
async wipeCaches(logoutDependant = false) {
// console.log('[CACHE-API] delete app level cache ');
if (!this.cachesAvailable) {
return;
}
await caches.delete(APP_LEVEL_CACHE);
for (const cache of this._dynamicCaches) {
if (!logoutDependant) {
await wipeCache(cache.name);
}
if (logoutDependant && cache.clearOnLogout) {
await wipeCache(cache.name);
}
}
}
getCachedRequest(request: HttpRequest<any>): Observable<HttpEvent<any>> {
const url = this._buildUrl(request);
// console.log('[CACHE-API] get cached request: ', url);
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();
}
// console.log('[CACHE-API] cache value: ', name);
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);
}
// console.log('[CACHE-API] get cached value: ', name);
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) {
// console.log('[CACHE-API] checking cache expiration');
if (!this.cachesAvailable) {
return;
}
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;
}
}