An extensible, isomorphic cache with modules to interface with Redis, web storage, IndexedDB and an in-memory Map.
- Use Redis or an in-memory Map on the server.
- Use LocalStorage, SessionStorage, IndexedDB or an in-memory Map on the client.
- Extend with custom modules to interface with key/value databases of your choosing.
- Save data as string, base64 encoded or encrypted.
- Transfer entries from one Cachemap to another.
- Store entries alongside cache-control directives, etags and uuids.
- Cache-control directives used to derive whether entries are fresh or stale.
- Prioritize entries based on metadata stored against each entry.
- Use a reaper to cull stale entries at specified intervals.
- Set maximum memory size for total entries.
- Use on browser's main or worker thread.
Cachemap is structured as a monorepo so each package is published to npm under the @cachemap
scope and can be installed in a project in the same way as any other npm package.
npm add @cachemap/<package>
So, for example, if you want a server cache that uses Redis you would install the packages below.
npm add @cachemap/core @cachemap/redis
If, however, you want a persisted client cache that uses IndexedDB and culls stale data you would install the following packages.
npm add @cachemap/core @cachemap/indexed-db @cachemap/reaper
The Cachemap's multi-package structure allows you to compose your cache of the modules you need, without additional bloat. Start with the @cachemap/core
package and build out from there.
- @cachemap/controller
- @cachemap/core
- @cachemap/core-worker
- @cachemap/indexed-db
- @cachemap/web-storage
- @cachemap/map
- @cachemap/reaper
- @cachemap/redis
- @cachemap/types
- @cachemap/utils
The Cachemap API is similar to that of a Map, it has clear
, delete
, entries
, get
, has
and set
methods, as well as Cachemap specific import
, export
and size
methods. Each module that interfaces with a database, referred to as a store, also has these methods. The API provides a simple and consistent way to communicate with key/value databases.
The Cachemap is initialized using the traditional class constructor. Any modules you want to add to the Cachemap, like the store to work with your database or the reaper to prune stale entries, are passed as properties to the constructor.
The default export of each module is a curried function that returns an async function that initializes the module. This allows you and the Cachemap to pass configuration options into the module.
import { Core, ValueFormat } from '@cachemap/core';
import { init as indexedDB } from '@cachemap/indexed-db';
import { init as reaper } from '@cachemap/reaper';
const cachemap = new Core({
name: 'foobar',
reaper: reaper({ interval: 300000 }),
store: indexedDB(),
type: 'someType',
valueFormatting: ValueFormat.Base64,
});
The example above initializes a persisted cache for the browser that uses IndexedDB as its database and checks for stale entries every five minutes. No other configuration is required, as long as the browser supports IndexedDB you are good to go.
The bread and butter of the Cachemap's functionality are the delete
, get
, has
, and set
methods. The input signatures of the first three are very similar, each excepts a key as the first argument and a set of options as the second. The set
method, meanwhile, excepts a key as the first argument, a value as the second and the third is the options.
An important set
option is cacheHeaders
. This takes a Headers instance or a plain object of HTTP headers. The etag and cache-control directives are filtered out and stored against an entry. The directives are used to generate a TTL (time to live) that the Cachemap checks whenever accessing the entry.
Another important set
option is tag
. This allows you to store an arbitrary identifier against an entry, like a request or session ID. These identifiers can come in handy, for example, if you want to export all entries added during a particular request.
All four methods have a hashKey
option that runs the key through md5
to create a short unique string, which can be useful if the original keys are long strings such as URLs or GraphQL queries.
(async () => {
const key = 'https://api.example.com/user/foobar';
const value = { email: 'foobar@example.com', id: '12345', name: 'foobar' };
const cacheHeaders = { cacheControl: 'private, max-age=60' };
await cachemap.set(key, value, { cacheHeaders, hashKey: true });
// returns undefined
const cacheability = await cachemap.has(key, { hashKey: true });
// returns an instance of the Cacheability module, which includes the
// cache-control directives and the TTL calculated from the directives
const entry = await cachemap.get(key, { hashKey: true });
// returns { email: 'foobar@example.com', id: '12345', name: 'foobar' }
const deleted = await cachemap.delete(key, { hashKey: true });
// returns true
})();
Sometimes you might want to add or retrieve multiple entries at once, and this is where the import
and export
methods come in. They allow you to bulk transfer entries and their metadata between two Cachemaps. You can export all entries or a subset based on specific keys or a particular tag.
This could be used to pass entries between a Cachemap on the server and one on the browser. The server Cachemap could, for example, export all entries and metadata added during a request to the server, then this could be serialized and embedded in the response body, from where it could be imported into the browser Cachemap.
(async () => {
const requestID = '6d91e84e-b14c-11e8-96f8-529269fb1459';
const entries = await cachemapOne.export({ tag: requestID });
// returns { entries: Array<[string, any]>, metadata: Array<{ [key: string]: any }> }
await cachemapTwo.import(entries);
// returns undefined
})();
To free up the browser's main thread you can run the Cachemap in a web worker. For this you need the @cachemap/core-worker
package in addition to @cachemap/core
, a store and optional reaper package.
The package exports the CoreWorker
class that you initialize on the main thread and the registerWorker
method to use in your worker.js
file. The CoreWorker
class method signatures are identical to those of the Core
class.
// main.js
import { CoreWorker } from '@cachemap/core-worker';
const cachemap = new CoreWorker({
name: 'integration-tests',
worker: new Worker('worker.js'),
type: 'someType',
});
// worker.js
import { Core, ValueFormat } from '@cachemap/core';
import { registerWorker } from '@cachemap/core-worker';
import { init as indexedDB } from '@cachemap/indexed-db';
import { init as reaper } from '@cachemap/reaper';
const cachemap = new Core({
name: 'worker-integration-tests',
reaper: reaper({ interval: 300000 }),
store: indexedDB(),
type: 'someType',
valueFormatting: ValueFormat.Base64,
});
registerWorker({ cachemap });
The example above initializes a persisted browser cache that runs on the worker thread and uses the IndexedDB and reaper modules.
Each Cachemap has its own event emitter on the emitter
property, which can be used to listen to events such as the 'ENTRY_DELTED'
event. This is emitted whenever an entry is deleted by the reaper. The data passed to the listener includes the entry key
, any tags
associated with the entry and whether it was successfully deleted
.
const cachemap = new Core({
name: 'worker-integration-tests',
reaper: reaper({ interval: 300000 }),
store: indexedDB(),
type: 'someType',
valueFormatting: ValueFormat.Base64,
});
cachemap.emitter.on(cachemap.events.ENTRY_DELETED, data => {
// DO SOMETHING
});
Sometimes you'll want to control multiple Cachemap instances that are not directly accessible. You can do this through the @cachemap/controller
package. The package exports an eventemitter-based module that has methods for clearing instances caches and starting/stopping each instance's reaper module.
Each Cachemap instance is listening for these events and will action them if the name
or type
props passed into the method match the instance's.
import { instance } from '@cachewmap/controller';
instance.clearCaches({ type: 'someType' });
instance.stopReapers({ type: 'someType' });
The Cachemap comes with four store modules, but you can create additional stores to work with key/value databases of your choosing. A store just has to adhere to the structure below. If you are writing in Typescript, you can even import the Store
interface from @cachemap/core
and have your store class implement that.
// store class must implement this interface
interface Store {
readonly maxHeapSize: number;
readonly name: string;
readonly type: string;
clear(): Promise<void>;
delete(key: string): Promise<boolean>;
entries(keys?: string[]): Promise<Array<[string, any]>>;
get(key: string): Promise<any>;
has(key: string): Promise<boolean>;
import(entries: Array<[string, any]>): Promise<void>;
set(key: string, value: any): Promise<void>;
size(): Promise<number>;
}
// async function must return store instance
type StoreInit = (options: { name: string }) => Promise<Store>;
Check out the features, fixes and more that go into each major, minor and patch version.
Cachemap is MIT Licensed.