import { decode as base64decode, encode as base64encode } from "https://deno.land/std@0.89.0/encoding/base64.ts"import { Context } from 'https://deno.land/x/oak@v10.6.0/mod.ts'import { connect, Redis } from "https://deno.land/x/redis@v0.26.0/mod.ts"import { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts"import PerfMetrics from './src/performanceMetrics.ts'import LRU from './src/lru.ts'
interface options { cache?: string; port?: number; hostname?: string; expire?: string | number; respondOnHit?: boolean; capacity?: number;}
export interface cacheValue { headers: {[k:string]:string}; body: Uint8Array; status: number;}
export class Zoic { capacity: number; expire: number; metrics: InstanceType <typeof PerfMetrics>; respondOnHit: boolean; cache: Promise < LRU | Redis >;
constructor (options?: options) { this.capacity = options?.capacity || Infinity; this.expire = this.#parseExpTime(options?.expire); this.metrics = new PerfMetrics(); this.respondOnHit = this.#setRespondOnHit(options); this.cache = this.#initCacheType(this.expire, this.metrics, options?.cache?.toUpperCase(), options?.port, options?.hostname);
this.use = this.use.bind(this); this.getMetrics = this.getMetrics.bind(this); this.endPerformanceMark = this.endPerformanceMark.bind(this); this.put = this.put.bind(this); }
async #initCacheType (expire: number, metrics: InstanceType<typeof PerfMetrics>, cache?: string, redisPort?: number, hostname?: string) { if (this.capacity <= 0) throw new TypeError('Cache capacity must exceed 0 entires.'); if (cache === 'REDIS') { if (!redisPort) { throw new Error('Redis requires port number passed in as an options property.'); } const redis = await connect({ hostname: hostname || '127.0.0.1', port: redisPort }); this.metrics.cacheType = 'Redis'; return redis; } return new LRU(expire, metrics, this.capacity); }
#parseExpTime (numberString?: string | number) { if (!numberString) return Infinity; let seconds; if (typeof numberString === 'string'){ seconds = numberString.trim().split(',').reduce((arr, el) => { if (el[el.length - 1] === 'd') return arr += parseInt(el.slice(0, -1)) * 86400; if (el[el.length - 1] === 'h') return arr += parseInt(el.slice(0, -1)) * 3600; if (el[el.length - 1] === 'm') return arr += parseInt(el.slice(0, -1)) * 60; if (el[el.length - 1] === 's') return arr += parseInt(el.slice(0, -1)); throw new TypeError( 'Cache expiration time must be string formatted as a numerical value followed by \'d\', \'h\', \'m\', or \'s\', or a number representing time in seconds.' ) }, 0); } else seconds = numberString; if (seconds > 31536000 || seconds < 0) throw new TypeError('Cache expiration time out of range.'); return seconds; }
#setRespondOnHit (options?: options) { if (options?.respondOnHit === undefined) return true; return options.respondOnHit; }
redisTypeCheck (cache: LRU | Redis): cache is Redis { return (cache as Redis).isConnected !== undefined; }
endPerformanceMark (queryRes: 'hit' | 'miss') { performance.mark('endingMark'); this.metrics.updateLatency( performance.measure('latency_timer', 'startingMark', 'endingMark') .duration, queryRes); queryRes === 'hit' ? this.metrics.readProcessed() : this.metrics.writeProcessed(); }
async use (ctx: Context, next: () => Promise<unknown>) { try {
const cache = await this.cache; performance.mark('startingMark'); const key: string = ctx.request.url.pathname + ctx.request.url.search; const cacheQueryResults = await cache.get(key);
if (!cacheQueryResults) {
if (this.metrics.numberOfEntries < this.capacity) this.metrics.addEntry(); this.#cacheResponse(ctx); return next(); }
if (this.redisTypeCheck(cache) && typeof cacheQueryResults === 'string') {
const parsedResults = cacheQueryResults.split('\n');
const { headers, status } = JSON.parse(atob(parsedResults[0])); const body = base64decode(parsedResults[1]);
if (this.respondOnHit) {
ctx.response.body = body; ctx.response.status = status; Object.keys(headers).forEach(key => { ctx.response.headers.set(key, headers[key]); });
this.endPerformanceMark('hit'); return; }
ctx.state.zoicResponse.body = body; ctx.state.zoicResponse.status = status; ctx.state.zoicResponse.headers = headers;
this.endPerformanceMark('hit'); return next(); }
if (!this.redisTypeCheck(cache) && typeof cacheQueryResults !== 'string'){ const { body, headers, status } = cacheQueryResults;
if (this.respondOnHit) {
ctx.response.body = body; ctx.response.status = status; Object.keys(headers).forEach(key => { ctx.response.headers.set(key, headers[key]); }); this.endPerformanceMark('hit'); return; } ctx.state.zoicResponse.body = JSON.parse(new TextDecoder().decode(body)); ctx.state.zoicResponse.headers = headers; ctx.state.zoicResponse.status = status;
this.endPerformanceMark('hit'); return next(); }
throw new Error('Cache query failed');
} catch (err) { ctx.response.status = 400; ctx.response.body = 'Error in Zoic.use. Check server logs for details.'; console.log(`Error in Zoic.use: ${err}`); } }
async #cacheResponse (ctx: Context) {
const cache = await this.cache; const redisTypeCheck = this.redisTypeCheck; const endPerformanceMark = this.endPerformanceMark; const toDomResponsePrePatch = ctx.response.toDomResponse;
ctx.response.toDomResponse = async function() {
const key: string = ctx.request.url.pathname + ctx.request.url.search; const nativeResponse = await toDomResponsePrePatch.apply(this); if (redisTypeCheck(cache)) { const body = await nativeResponse.clone().arrayBuffer();
const headerAndStatus = { headers: Object.fromEntries(nativeResponse.headers.entries()), status: nativeResponse.status };
cache.set(key,`${btoa(JSON.stringify(headerAndStatus))}\n${base64encode(new Uint8Array(body))}`); } if (!redisTypeCheck(cache)) { const arrBuffer = await nativeResponse.clone().arrayBuffer();
const responseToCache: cacheValue = { body: new Uint8Array(arrBuffer), headers: Object.fromEntries(nativeResponse.headers.entries()), status: nativeResponse.status };
const headerBytes = Object.entries(responseToCache.headers) .reduce((acc: number, headerArr: Array<string>) => { return acc += (headerArr[0].length * 2) + (headerArr[1].length * 2); }, 0);
const resByteLength = (key.length * 2) + responseToCache.body.byteLength + headerBytes + 34; cache.put(key, responseToCache, resByteLength); } endPerformanceMark('miss');
return new Promise (resolve => { resolve(nativeResponse); }); }
return; }
async clear (ctx: Context, next: () => Promise<unknown>) { const cache = await this.cache; this.redisTypeCheck(cache) ? cache.flushdb() : cache.clear(); this.metrics.clearEntires(); return next() }
getMetrics (ctx: Context) { try {
const enableRouteCors = oakCors(); return enableRouteCors(ctx, async () => {
const cache = await this.cache; const { cacheType, memoryUsed, numberOfEntries, readsProcessed, writesProcessed, missLatencyTotal, hitLatencyTotal } = this.metrics; ctx.response.headers.set('Access-Control-Allow-Origin', '*');
if (this.redisTypeCheck(cache)) { const redisInfo = await cache.info(); const redisSize = await cache.dbsize(); const infoArr: string[] = redisInfo.split('\r\n');
ctx.response.body = { cache_type: cacheType, number_of_entries: redisSize, memory_used: infoArr?.find((line: string) => line.match(/used_memory/))?.split(':')[1], reads_processed: infoArr?.find((line: string) => line.match(/keyspace_hits/))?.split(':')[1], writes_processed: infoArr?.find((line: string) => line.match(/keyspace_misses/))?.split(':')[1], average_hit_latency: hitLatencyTotal / readsProcessed, average_miss_latency: missLatencyTotal / writesProcessed } return; } ctx.response.body = { cache_type: cacheType, memory_used: memoryUsed, number_of_entries: numberOfEntries, reads_processed: readsProcessed, writes_processed: writesProcessed, average_hit_latency: hitLatencyTotal / readsProcessed, average_miss_latency: missLatencyTotal / writesProcessed } return; }) } catch (err) { ctx.response.status = 400; ctx.response.body = 'Error in Zoic.getMetrics. Check server logs for details.'; console.log(`Error in Zoic.getMetrics: ${err}`); } }
put (ctx: Context, next: () => Promise<unknown>) { try { performance.mark('startingMark');
if (this.metrics.numberOfEntries < this.capacity) this.metrics.addEntry();
this.#cacheResponse(ctx);
return next();
} catch (err) { ctx.response.status = 400; ctx.response.body = 'Error in Zoic.put. Check server logs for details.'; console.log(`Error in Zoic.put: ${err}`); } }}
export default Zoic;