express-pubsubcache
v3.0.1
Published
a request caching library that uses a write-based cache eviction strategy based on the pub sub pattern
Downloads
16
Maintainers
Readme
Module Overview
Name: GlobalRouteCache
Description: This class provides all the required APIs to work with the library. you don't need to import additional exports.
Import Syntax:
import { GlobalRouteCache } from "express-pubsubcache";
// or
import GlobalRouteCache from "express-pubsubcache";
Methods and interfaces
1. Name
ConfigureGlobalCache
2. Description
By default, the library uses a javascript map to hold the response cache data. However, It fully supports any storage type of your choice for caching (SQLite, Memcached, Redis, etc.). All you have to do is write an adapter that implements this interface:
export type CachedResponseType = {
body?: unknown,
statusCode?: number,
headers?: {} & Express.Locals,
};
export interface GlobalCacheInterface {
evict: (key: string) => Promise<void>;
get: (key: string) => Promise<string | null>;
set: (key: string, value: any) => Promise<void>;
data?: Map<string, any>;
deserializer: (body: string) => CachedResponseType;
serializer: (opts: CachedResponseType) => string;
cleanup: () => Promise<void>;
}
3. Arguments
- func:
() => GlobalCacheInterface
- A function that returns an implementation of theGlobalCacheInterface
type, which will be used as the response cache adapter
4. Example
// implement the interface
export class cacheClass implements GlobalCacheInterface {
constructor() {}
deserializer(body: string) {
// YOUR IMPLEMENTATION
}
serializer(body: CachedResponseType) {
// YOUR IMPLEMENTATION
}
async evict(key: string): Promise<void> {
// YOUR IMPLEMENTATION
}
async set(key: string, value: any): Promise<void> {
// YOUR IMPLEMENTATION
}
async get(key: string): Promise<any | undefined> {
// YOUR IMPLEMENTATION
}
async cleanup(): Promise<void> {
// YOUR IMPLEMENTATION
}
}
// then configure to use the implementation
GlobalRouteCache.configureGlobalCache(() => new cacheClass());
1. Name
configureGlobalCacheSerializer
2. Description
If you want to override the default behavior of the serializing logic of the response cache, you can provide your own configuration.
NOTE that this should be called at the top level of the scope of your application. Otherwise, the default will be used in scopes where your initialization is not visible.
3. Arguments
- func:
(body: CachedResponseType) => string
- A function that takes in an input of type CachedResponseType and returns a string.
4. Example
// at the top level
GlobalRouteCache.configureGlobalCacheSerializer((res) => JSON.stringify(res));
//... rest of your application
1. Name
configureGlobalCacheDeserializer
2. Description
If you want to override the default behavior of the deserializing logic of the response cache, you can provide your own configuration.
NOTE that this should be called at the top level of the scope of your application. Otherwise, the default will be used in scopes where your initialization is not visible.
3. Arguments
- func:
(body: string) => CachedResponseType
- A function that takes in a string and returns an object of type CachedResponseType.
4. Example
// at the top level
GlobalRouteCache.configureGlobalCacheDeserializer((res) => JSON.parse(res));
//... rest of your application
1. Name
flushGlobalCache
2. Description
This should be called whenever you want to do a cleanup of your cache for some reasons (e.g, removing all the cached responses after a database or an API schema change).
NOTE You might be tempted to do something like
GlobalRouteCache.channel.cache.cleanup()
. This is not advisable as you stand the risk of getting a stale internal state
3. Arguments
- arg:
void
- this method takes in no arguments.
4. Example
// anywhere it makes sense to invoke
await GlobalRouteCache.flushGlobalCache();
//... rest of your application
1. Name
createCacheSubscriber
2. Description
This method subscribes the current endpoint into caching and populates the res.local.cachedResponse field with a value of type CacheSubscriberOpt for further processing in your route handlers.Your route will always get the same cached data in the res.local.cachedResponse field of the current route handler unless a corresponding publisher
(usually an equivalent POST/PUT/PATCH/DELETE handler) is set for that endpoint. So, if you just want a time-based caching for a route, then you should consider using http headers instead for that route.
3. Arguments
opts?: CacheSubscriberOpt - this is an optional config object to specify the behavior of the current subscription. here are the two types of behaviors you can get based on the these option fields:
- opts.catchAll : if set to true, the current subscription will behave like a wild card so that whenever a
publisher
publishes a matching wildcard, the cache is evicted for all routes matching the current route's wildcard (this corresponds toreq.baseUrl
+req.route.path
in express) If not set, the current subscription will only be tied to the literal route (req.baseUrl
+req.url
). Consequently, apublisher
for the current route (whether a 'catchAll'publisher
or not - so long as it matches) will be able to trigger a cache eviction for it
- opts.catchAll : if set to true, the current subscription will behave like a wild card so that whenever a
NOTE the 'catchAll' option field might not always go well with dynamic routes but can be useful in situations where you want to return the same cached data regardless of a params change in the url (e.g /users/1, /users/2, ...), of the current route handler
4. Example
// in your route handlers
app.get(
"/users/:user_id",
GlobalRouteCache.createCacheSubscriber(), // subscribe just this literal route (e.g, /users/2) to caching
async (req, res) => {
const { user_id } = req.params;
// cache hit
if (res.locals.cachedResponse) {
// same cached data will be retrieved for any GET request to this route (/users/2)
// until a publisher publishes to '/users/2' or any matching wildcard (e.g /users/:user_id, /*)
return res
.status(res.locals.cachedResponse.statusCode)
.set(res.locals.cachedResponse.headers)
.send(res.locals.cachedResponse.body);
}
// cache miss
const user = users[user_id];
if (user) {
await delay(DELAY_INTERVAL); // some data fetching logic that is supposed to take time
res.json(user);
} else {
res.status(404).send({ error: "User not found" });
}
}
);
//... rest of your code
// in your route handlers
app.get(
"/users/:user_id",
GlobalRouteCache.createCacheSubscriber({ catchAll: true }), // this subscription will behave like a wild card (/users/:user_id), subscribing the literal route '/users/:user_id' to caching
async (req, res) => {
const { user_id } = req.params;
// cache hit
if (res.locals.cachedResponse) {
// same cached data will be retrieved for *any* GET request to this route (e.g /users/1, /users/2, ...)
// until a publisher publishes to '/users/:user_id' or any matching wildcard (e.g /*, /users/*)
return res
.status(res.locals.cachedResponse.statusCode)
.set(res.locals.cachedResponse.headers)
.send(res.locals.cachedResponse.body);
}
// cache miss
const user = users[user_id];
if (user) {
await delay(DELAY_INTERVAL); // some data fetching logic that is supposed to take time
res.json(user);
} else {
res.status(404).send({ error: "User not found" });
}
}
);
//... rest of your code
1. Name
createCachePublisher
2. Description
This method creates a publisher
that notifies all the subscriber
s on the route of the received endpoint (or other endpoints, more on that later) to evict their caches.This is usually called in route handlers that cause mutations (e.g, POST, PUT, DELETE,...).
3. Arguments
opts?: CachePublisherOpt - this is an optional config object to specify the behavior of the current
publisher
and optionally cascade the published event to unrelated subscribers. here are the three types of behaviors you can get based on the these option fields:- opts.catchAll : If set to true, the current
publisher
will behave like a wild card so that it notifies allsubscriber
s to routes that are matching the current route's wildcard (req.baseUrl
+req.url
in express), to evict their caches If not set, the currentpublisher
will only notify thesubscriber
of the literal route (req.baseUrl
+req.url
) and consequently, only the literal route's cache is evicted - opts.cascade : Additionally, you can provide an array of routes (usually wildcards) to which the current published event is cascaded. This is basically the
publisher
's way notifyingsubscriber
s that wouldn't have otherwise been notified -subscriber
s to routes that do not match the current route or the current route's wildcard (depending onopts.catchAll
) - opts.freeze : this allows the
publisher
to notify thesubsriber
s to the current route's wildcard without propagating to matching literal routes. this may be useful for optimization purposesNOTE That the 'freeze' option field should be set if and only if the 'catchAll' option field is. Otherwise, the behavior is undefined. Also, this should only be set if you know when to use it. Otherwise, you risk getting a stale cache data - the default configuration is usually sufficient for most cases
- opts.catchAll : If set to true, the current
4. Example
// in your route handlers
app.post("/users", GlobalRouteCache.createCachePublisher(), (req, res) => {
// this publisher will notify all subscribers to '/users' route to evict their caches. Hence the next GET on '/users' will be a cache miss
// ... rest of your route handler logic
});
//... rest of your code
With 'cascade' option field
// in your route handlers
app.delete(
"/users/:user_id",
GlobalRouteCache.createCachePublisher({ cascade: ["/users"] }) // this won't trigger an eviction for the cache on '/users' unless you explicitly include it in the 'cascade' option field
// for the simple reason that '/users' does not match '/users/1' (assuming that is the current route)
// however, the '/users/1' cache is evicted (again, assuming that is the current route)
// ... rest of your route handler logic
);
//... rest of your code
With 'catchAll' option field
// in your route handlers
app.delete(
"/users/:user_id",
GlobalRouteCache.createCachePublisher({ catchAll: true, cascade: ["/users"] }) // similar to the previous example in behavior except 'catchAll' is set to true. Therefore, this publisher will notify all subscribers to literal routes matching '/users/:user_id' route to evict their caches. Hence the next GET on '/users/11', '/users/2', '/users/208', ... will all be cache misses
// ... rest of your route handler logic
);
//... rest of your code
With 'freeze' option field
// say one of your GET handlers is registering a subscriber with a catchAll option field set to true
app.get(
"/users/:user_id",
GlobalRouteCache.createCacheSubscriber({ catchAll: true }) // this subscription will behave like a wild card (/users/:user_id), subscribing the literal route '/users/:user_id' to caching
/// ...
);
// and you want to trigger the eviction for just that cache somewhere else
app.delete(
"/users/:user_id",
GlobalRouteCache.createCachePublisher({ catchAll: true, freeze: true }) //
// without 'catchAll' set to true, it will only evict the cache of the current literal route (e.g, '/users/1')
// now, it will be able to evict the cache of all matching routes ('/users/1', '/users/2', ... etc)
// but wait a minute!, the 'freeze' is set to true. so, it evicts just the cache of '/users/:user_id'
// which is an exact match of the subscriber previously registered
// ... rest of your route handler logic
);
//... rest of your code
Additional APIs
pub
Description
Take a look at this example:
// ... app.delete( "/users/:user_id", GlobalRouteCache.createCachePublisher({ catchAll: true, freeze: true, cascade: ["/users/:user_id/news/:news_id"], }) ); // ...
Here, the behavior of the cascaded events will depend on whether the freeze option field is set on the original createCachePublisher method (which in this case is) if you want different behaviors for each published event, you should publish them individually using the
GlobalRouteCache.pub
, providing the first and second arguments as the route and a boolean respectively as show below:// ... app.delete( "/users/:user_id", GlobalRouteCache.createCachePublisher({ catchAll: true, freeze: true, cascade: ["/users/:user_id/news/:news_id"], }), async (req, res, next) => { // '/users/:user_id/news/:news_id' will evict its cache but it won't propagate to matching children routes. GlobalRouteCache.pub("/users/:user_id/news", false); // the cache eviction will propagate to matching children routes (e.g '/users/1/news', '/users/2/news') // ... } ); // ...
this boolean corresponds to the 'freeze' option field
Concepts
glob subscriber
Subscribes to all events and tells thechannel
to hold a cache using a global key ("*"), then evicts the cache whenever an event is produced by anypublisher
.group subscriber
Subscribes to a specified group of events using a wildcard expression (based on the url pattern). it tells thechannel
to hold a cache for this route group - using the url pattern as key, then evicts the cache whenever an event is produced by anypublisher
on routes with wildcard expressions matching its wildcard.subscriber
Subscribes to a single event using a literal string expression (based on the url literal). it tells the channel to hold a cache for this route - using the url literal as key, then evicts the cache whenever an event is produced by its correspondingpublisher
(if any) or anypublisher
on routes with wildcard expressions matching its literal key.glob publisher
Produces an event that triggers cache eviction for all subscribers using a "*" expressiongroup publisher
Produces an event that triggers cache eviction for a subset of subscribers using a wild card expression. note that this is not a direct 'flip' of a group subscriberpublisher
Produces an event that triggers cache eviction for a corresponding subscriber (based on the url literal). note that this is not a direct 'flip' of a subscriberevent
This is an action produced by invoking apublisher
. It is tied to the url string passed during the registration of thepublisher
- which may or may not be subscribed to by asubscriber
/groupSubscriber
.channel
This is like a message broker in that it manages the transmission of events frompublisher
s to their correspondingsubscriber
s
Types
Work in progress...
Changelog
v1.0.0
- @
1.x.x
first iterations (unstable).
- @
v2.0.0
- @
2.0.0
wrapped the original GlobalRouteCache class into a proxy class that intercepts the override of any of the apis.optimized the cache eviction logic for a group of routes.
- @
2.0.1
updated types, adjusted implementation to accomodate changes
- @
v3.0.0
- @
3.0.0
updated types, added more options in methods, updated the proxy object to prohibit users from accessing some internal methods. updated the code to allow children wildcard events be triggered when a wildcard is published, added a 'freeze' flag to make allow for eviction of just the literal wildcard events without cascade, exposed more apis for extended usage. updated the test suite
- @