koa-cash
Version:
HTTP response caching for Koa. HTTP response caching for Koa. Supports Redis, in-memory store, and more!
192 lines (163 loc) • 5.18 kB
JavaScript
const path = require('node:path');
const { Buffer } = require('node:buffer');
const { gzip } = require('node:zlib');
const { promisify } = require('node:util');
const bytes = require('bytes');
const compressible = require('compressible');
const getStream = require('get-stream');
const isJSON = require('koa-is-json');
const isStream = require('is-stream');
const safeStringify = require('fast-safe-stringify');
const compress = promisify(gzip);
// methods we cache
const defaultMethods = {
HEAD: true,
GET: true
};
//
// text/plain extensions
// <https://github.com/jshttp/mime-db/blob/3145b8fd1a082730eb57540f68421b081909b651/db.json#L8373>
// - txt
// - text
// - conf
// - def
// - list
// - log
// - in
// - ini
//
const TXT_EXTENSIONS = new Set([
'txt',
'text',
'conf',
'def',
'list',
'log',
'in',
'ini'
]);
module.exports = function (options) {
options ||= { compression: false, setCachedHeader: false };
const methods = Object.assign(defaultMethods, options.methods);
const hash =
options.hash ||
function (ctx) {
return ctx.request.url;
};
const stringify = options.stringify || safeStringify || JSON.stringify;
let threshold = options.threshold || '1kb';
if (typeof threshold === 'string') threshold = bytes(threshold);
const { get } = options;
const { set } = options;
if (!get) throw new Error('.get not defined');
if (!set) throw new Error('.set not defined');
// allow for manual cache clearing
function cashClear(key) {
// console.log(`Removing cache key: ${key}`);
set(key, false);
}
// ctx.cashed(maxAge) => boolean
async function cashed(maxAge) {
// uncacheable request method
if (!methods[this.request.method]) return false;
this.cashKey = hash(this);
const key = this.cashKey;
const obj = await get(key, maxAge || options.maxAge || 0);
const body = obj && obj.body;
if (!body) {
// tell the upstream middleware to cache this response
this.cash = { maxAge };
return false;
}
// serve from cache
if (obj.type) this.response.type = obj.type;
if (obj.lastModified) this.response.lastModified = obj.lastModified;
if (obj.etag) this.response.etag = obj.etag;
if (options.setCachedHeader) this.response.set('X-Cached-Response', 'HIT');
if (this.request.fresh) {
this.response.status = 304;
return true;
}
if (
options.compression &&
obj.gzip &&
this.request.acceptsEncodings('gzip', 'identity') === 'gzip'
) {
this.response.body = Buffer.from(obj.gzip);
this.response.set('Content-Encoding', 'gzip');
} else {
this.response.body = obj.body;
// tell any compress middleware to not bother compressing this
if (options.compression) {
this.response.set('Content-Encoding', 'identity');
}
}
return true;
}
// the actual middleware
// eslint-disable-next-line complexity
async function middleware(ctx, next) {
ctx.vary('Accept-Encoding');
ctx.cashed = cashed.bind(ctx);
ctx.cashClear = cashClear.bind(ctx);
await next();
// check for HTTP caching just in case
if (!ctx.cash) {
if (ctx.request.fresh) ctx.response.status = 304;
return;
}
// cache the response
// only cache GET/HEAD 200s
if (ctx.response.status !== 200) return;
if (!methods[ctx.request.method]) return;
let { body } = ctx.response;
if (!body) return;
// stringify JSON bodies
if (isJSON(body)) {
ctx.response.body = stringify(body);
body = ctx.response.body;
} else if (isStream(body)) {
// buffer streams
ctx.response.body = await getStream.buffer(body);
body = ctx.response.body;
}
// avoid any potential errors with middleware ordering
if ((ctx.response.get('Content-Encoding') || 'identity') !== 'identity') {
throw new Error('Place koa-cache below any compression middleware.');
}
const obj = {
body,
type: ctx.response.get('Content-Type') || null,
lastModified: ctx.response.lastModified || null,
etag: ctx.response.get('etag') || null
};
//
// if the content-type was `text` or `text/plain` then don't cache
// (since it's likely cache poisoning or the default Koa `text` being used)
//
if (obj.type === 'text' || obj.type === 'text/plain') {
const ext = path.extname(ctx.path);
if (ext && !TXT_EXTENSIONS.has(ext.toLowerCase())) obj.type = null;
}
const { fresh } = ctx.request;
if (fresh) ctx.response.status = 304;
if (
options.compression &&
compressible(obj.type) &&
ctx.response.length >= threshold
) {
obj.gzip = await compress(body);
if (
!fresh &&
ctx.request.acceptsEncodings('gzip', 'identity') === 'gzip'
) {
ctx.response.body = obj.gzip;
ctx.response.set('Content-Encoding', 'gzip');
}
}
if (options.compression && !ctx.response.get('Content-Encoding'))
ctx.response.set('Content-Encoding', 'identity');
await set(ctx.cashKey, obj, ctx.cash.maxAge || options.maxAge || 0);
}
return middleware;
};