diff --git a/src/index.ts b/src/index.ts index cd2b933..65baca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,3 +16,4 @@ export * from './lib/error'; export * from './lib/search'; export * from './lib/empty-states'; export * from './lib/scrollbar'; +export * from './lib/caching'; diff --git a/src/lib/caching/cache-api.service.ts b/src/lib/caching/cache-api.service.ts new file mode 100644 index 0000000..856cfed --- /dev/null +++ b/src/lib/caching/cache-api.service.ts @@ -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, httpResponse: HttpResponse) { + 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): Observable> { + 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 { + 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 { + 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) { + 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) { + 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; + } +} diff --git a/src/lib/caching/cache-utils.ts b/src/lib/caching/cache-utils.ts new file mode 100644 index 0000000..4e406d6 --- /dev/null +++ b/src/lib/caching/cache-utils.ts @@ -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('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 }); +} diff --git a/src/lib/caching/caching.module.ts b/src/lib/caching/caching.module.ts new file mode 100644 index 0000000..7de8d1e --- /dev/null +++ b/src/lib/caching/caching.module.ts @@ -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 { + return { + ngModule: CachingModule, + providers: [ + CacheApiService, + { + provide: HTTP_INTERCEPTORS, + multi: true, + useClass: HttpCacheInterceptor, + }, + { + provide: DYNAMIC_CACHES, + useValue: dynamicCaches, + }, + ], + }; + } +} diff --git a/src/lib/caching/dynamic-cache.ts b/src/lib/caching/dynamic-cache.ts new file mode 100644 index 0000000..d9cc4c4 --- /dev/null +++ b/src/lib/caching/dynamic-cache.ts @@ -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; diff --git a/src/lib/caching/http-cache-interceptor.ts b/src/lib/caching/http-cache-interceptor.ts new file mode 100644 index 0000000..a76b50d --- /dev/null +++ b/src/lib/caching/http-cache-interceptor.ts @@ -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, next: HttpHandler): Observable> { + 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, next: HttpHandler): Observable> { + 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); + } + }), + ); + } +} diff --git a/src/lib/caching/index.ts b/src/lib/caching/index.ts new file mode 100644 index 0000000..4033ac8 --- /dev/null +++ b/src/lib/caching/index.ts @@ -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';