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, httpResponse: HttpResponse) { 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): Observable> { 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 { 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 { 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) { 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) { // 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> { const cloned = response.clone(); const contentType = response.headers.get('content-type')?.toLowerCase(); let obs: Observable = 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, 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)[key] = httpResponse.headers.get(key) ?? ''; }); (cachedResponseFields.headers as Record)['_expires'] = expires.toString(); let body; const contentType = (cachedResponseFields.headers as Record)['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; } }