Cachified
🤑 wrap virtually everything that can store by key to act as cache with ttl/max-age, stale-while-validate, parallel fetch protection and type-safety support
Install / Use
/learn @epicweb-dev/CachifiedREADME
npm install @epic-web/cachified
<div align="center">
<a
alt="Epic Web logo"
href="https://www.epicweb.dev"
>
<img
width="300px"
src="https://github-production-user-asset-6210df.s3.amazonaws.com/1500684/257881576-fd66040b-679f-4f25-b0d0-ab886a14909a.png"
/>
</a>
</div>
<hr />
<!-- prettier-ignore-start -->
[![Build Status][build-badge]][build] [![MIT License][license-badge]][license] [![Code of Conduct][coc-badge]][coc]
<!-- prettier-ignore-end -->Watch the talk "Caching for Cash 🤑" on EpicWeb.dev:
Install
npm install @epic-web/cachified
# yarn add @epic-web/cachified
Usage
<!-- usage-intro -->import { LRUCache } from 'lru-cache';
import { cachified, CacheEntry, Cache, totalTtl } from '@epic-web/cachified';
/* lru cache is not part of this package but a simple non-persistent cache */
const lruInstance = new LRUCache<string, CacheEntry>({ max: 1000 });
const lru: Cache = {
set(key, value) {
const ttl = totalTtl(value?.metadata);
return lruInstance.set(key, value, {
ttl: ttl === Infinity ? undefined : ttl,
start: value?.metadata?.createdTime,
});
},
get(key) {
return lruInstance.get(key);
},
delete(key) {
return lruInstance.delete(key);
},
};
function getUserById(userId: number) {
return cachified({
key: `user-${userId}`,
cache: lru,
async getFreshValue() {
/* Normally we want to either use a type-safe API or `checkValue` but
to keep this example simple we work with `any` */
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
/* 5 minutes until cache gets invalid
* Optional, defaults to Infinity */
ttl: 300_000,
});
}
// Let's get through some calls of `getUserById`:
console.log(await getUserById(1));
// > logs the user with ID 1
// Cache was empty, `getFreshValue` got invoked and fetched the user-data that
// is now cached for 5 minutes
// 2 minutes later
console.log(await getUserById(1));
// > logs the exact same user-data
// Cache was filled an valid. `getFreshValue` was not invoked
// 10 minutes later
console.log(await getUserById(1));
// > logs the user with ID 1 that might have updated fields
// Cache timed out, `getFreshValue` got invoked to fetch a fresh copy of the user
// that now replaces current cache entry and is cached for 5 minutes
Options
<!-- ignore -->interface CachifiedOptions<Value> {
/**
* Required
*
* The key this value is cached by
* Must be unique for each value
*/
key: string;
/**
* Required
*
* Cache implementation to use
*
* Must conform with signature
* - set(key: string, value: object): void | Promise<void>
* - get(key: string): object | Promise<object>
* - delete(key: string): void | Promise<void>
*/
cache: Cache;
/**
* Required
*
* Function that is called when no valid value is in cache for given key
* Basically what we would do if we wouldn't use a cache
*
* Can be async and must return fresh value or throw
*
* receives context object as argument
* - context.metadata.ttl?: number
* - context.metadata.swr?: number
* - context.metadata.createdTime: number
* - context.background: boolean
*/
getFreshValue: GetFreshValue<Value>;
/**
* Time To Live; often also referred to as max age
*
* Amount of milliseconds the value should stay in cache
* before we get a fresh one
*
* Setting any negative value will disable caching
* Can be infinite
*
* Default: `Infinity`
*/
ttl?: number;
/**
* Amount of milliseconds that a value with exceeded ttl is still returned
* while a fresh value is refreshed in the background
*
* Should be positive, can be infinite
*
* Default: `0`
*/
staleWhileRevalidate?: number;
/**
* Alias for staleWhileRevalidate
*/
swr?: number;
/**
* Validator that checks every cached and fresh value to ensure type safety
*
* Can be a standard schema validator or a custom validator function
* @see https://github.com/standard-schema/standard-schema?tab=readme-ov-file#what-schema-libraries-implement-the-spec
*
* Value considered ok when:
* - schema succeeds
* - validator returns
* - true
* - migrate(newValue)
* - undefined
* - null
*
* Value considered bad when:
* - schema throws
* - validator:
* - returns false
* - returns reason as string
* - throws
*
* A validator function receives two arguments:
* 1. the value
* 2. a migrate callback, see https://github.com/epicweb-dev/cachified#migrating-values
*
* Default: `undefined` - no validation
*/
checkValue?:
| CheckValue<Value>
| StandardSchemaV1<unknown, Value>
| Schema<Value, unknown>;
/**
* Set true to not even try reading the currently cached value
*
* Will write new value to cache even when cached value is
* still valid.
*
* Default: `false`
*/
forceFresh?: boolean;
/**
* Whether or not to fall back to cache when getting a forced fresh value
* fails
*
* Can also be a positive number as the maximum age in milliseconds that a
* fallback value might have
*
* Default: `Infinity`
*/
fallbackToCache?: boolean | number;
/**
* Promises passed to `waitUntil` represent background tasks which must be
* completed before the server can shutdown. e.g. swr cache revalidation
*
* Useful for serverless environments such as Cloudflare Workers.
*
* Default: `undefined`
*/
waitUntil?: (promise: Promise<unknown>) => void;
/**
* Trace ID for debugging, is stored along cache metadata and can be accessed
* in `getFreshValue` and reporter
*/
traceId?: any;
}
Adapters
There are some adapters available for common caches. Using them makes sure the used caches cleanup outdated values themselves.
- Adapter for redis : cachified-redis-adapter
- Adapter for redis-json : cachified-redis-json-adapter
- Adapter for Cloudflare KV : cachified-adapter-cloudflare-kv repository
- Adapter for SQLite : cachified-adapter-sqlite
Advanced Usage
Stale while revalidate
Specify a time window in which a cached value is returned even though it's ttl is exceeded while the cache is updated in the background for the next call.
<!-- stale-while-revalidate -->import { cachified } from '@epic-web/cachified';
const cache = new Map();
function getUserById(userId: number) {
return cachified({
ttl: 120_000 /* Two minutes */,
staleWhileRevalidate: 300_000 /* Five minutes */,
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
// > logs the user with ID 1
// Cache is empty, `getFreshValue` gets invoked and and its value returned and
// cached for 7 minutes total. After 2 minutes the cache will start refreshing in background
// 30 seconds later
console.log(await getUserById(1));
// > logs the exact same user-data
// Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned
// 4 minutes later
console.log(await getUserById(1));
// > logs the exact same user-data
// Cache timed out but stale while revalidate is not exceeded.
// cached value is returned immediately, `getFreshValue` gets invoked in the
// background and its value is cached for the next 7 minutes
// 30 seconds later
console.log(await getUserById(1));
// > logs fresh user-data from the previous call
// Cache is filled an valid. `getFreshValue` is not invoked, cached value is returned
Forcing fresh values and falling back to cache
We can use forceFresh to get a fresh value regardless of the values ttl or stale while validate
import { cachified } from '@epic-web/cachified';
const cache = new Map();
function getUserById(userId: number, forceFresh?: boolean) {
return cachified({
forceFresh,
/* when getting a forced fresh value fails we fall back to cached value
as long as it's not older then 5 minutes */
fallbackToCache: 300_000 /* 5 minutes, defaults to Infinity */,
cache,
key: `user-${userId}`,
async getFreshValue() {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`,
);
return response.json();
},
});
}
console.log(await getUserById(1));
// > logs the user with ID 1
// Cache is empty, `getFreshValue` gets invoked and and its value returned
console.log(await getUserById(1, true));
// > logs fresh user with ID 1
// Cache is filled an valid. but we forced a fresh value, so `getFreshValue` is invoked
Type-safety
In practice we can not be entirely sure that values

