-
Notifications
You must be signed in to change notification settings - Fork 2
Cacher
Cross-runtime caching with a unified, TTL-aware API over Memory, Redis, Memcached and Cloudflare Workers KV engines — for Deno, Bun, and Node.js.
The Cacher package provides a unified caching abstraction that works with multiple backends. A singleton Cacher manager handles engine registration and instance lifecycle, while AbstractEngine defines the common API that all cache engines implement.
The MEMORY engine is process-local and works on every runtime — Deno,
Bun, Node, Cloudflare Workers and the browser. The REDIS and
MEMCACHED engines need a reachable TCP target, dialed through
@tundralibs/drivers' RedisEngine/MemcachedEngine, which connect via
@tundralibs/compat/net's connect(). On Workers that now runs on
real TCP through cloudflare:sockets — no nodejs_compat flag needed —
so both engines work there. In a plain browser they still don't:
a browser has no raw TCP at all, cloudflare:sockets included.
The WORKERS_KV engine stores entries in a Workers KV namespace. It needs
the namespace binding from the Worker's env, so it runs only in a Worker or
under Miniflare. It has no REST mode for other runtimes.
RedisCacher/MemCacher statically import those @tundralibs/drivers
engines at module top level, so importing @tundralibs/cacher never
throws or fails to bundle anywhere — the driver classes load fine; it is
only the socket that a target lacks. The connection is deferred to
connect(), so a browser bundle that only ever touches MemoryCacher
runs fine and a Redis/Memcached engine fails only if you actually try to
connect() it.
| Module | Description | Documentation |
|---|---|---|
Cacher (default) |
Singleton manager for engine registration and cache instance creation | This page |
AbstractEngine |
Base class for custom cache engine implementations | Custom Engine |
./engines |
Built-in engines: Memory, Redis, Memcached, Workers KV | Cacher-Engines |
./errors |
CacherError and CacherEngineError error classes |
Cacher-Errors |
./types |
CacherOptions, CacheValue, CacheValueOptions
|
— |
- Engines Overview — All built-in cache engines and their common API
- Memory Engine — In-process cache, no dependencies
- Redis Engine — Redis-backed cache with TLS support
- Memcached Engine — Memcached-backed cache with TLS support
- Workers KV Engine — Cloudflare Workers KV-backed cache for Workers
- Errors — Error classes and error code reference
Deno:
deno add @tundralibs/cacherBun:
bunx jsr add @tundralibs/cacherNode.js:
npx jsr add @tundralibs/cacherimport { Cacher } from '@tundralibs/cacher';
// Create a memory cache instance
const cache = Cacher.create('MEMORY', 'my-cache', {
defaultExpiry: 300, // 5 minutes
});
// Store a value
await cache.set('user:1', { name: 'Alice', role: 'admin' });
// Retrieve a value
const user = await cache.get<{ name: string; role: string }>('user:1');
console.log(user?.name); // 'Alice'
// Check existence
if (await cache.has('user:1')) {
console.log('User is cached');
}
// Delete a value
await cache.delete('user:1');
// Clear all entries
await cache.clear();import { Cacher } from '@tundralibs/cacher';
const cache = Cacher.create('REDIS', 'session-cache', {
host: 'localhost',
port: 6379,
password: 'secret',
db: 0,
defaultExpiry: 3600, // 1 hour
});
await cache.set('session:abc123', {
userId: 42,
expires: Date.now() + 3600000,
});
const session = await cache.get<{ userId: number; expires: number }>(
'session:abc123',
);import { Cacher } from '@tundralibs/cacher';
const cache = Cacher.create('MEMCACHED', 'object-cache', {
host: 'localhost',
port: 11211,
defaultExpiry: 600,
});
await cache.set('product:1', { id: 1, name: 'Widget', price: 9.99 });import { Cacher, type WorkersKVNamespace } from '@tundralibs/cacher';
declare const env: { CACHE: WorkersKVNamespace }; // the Worker's env
const cache = Cacher.create('WORKERS_KV', 'page-cache', {
binding: env.CACHE,
defaultExpiry: 600,
});
await cache.set('page:home', { title: 'Home' });In-process cache with no external dependencies.
| Option | Type | Default | Description |
|---|---|---|---|
defaultExpiry |
number |
300 |
Default TTL in seconds. 0 = no expiry (max 2592000 = 30 days) |
Uses a Redis server as the cache backend.
| Option | Type | Default | Description |
|---|---|---|---|
host |
string |
required | Redis server hostname |
port |
number |
6379 |
Redis server port |
username |
string |
— | Optional Redis username |
password |
string |
— | Optional Redis password |
db |
number |
— | Optional Redis database number |
ssl |
boolean | EngineSSLOptions |
— | TLS configuration (true for defaults) |
defaultExpiry |
number |
300 |
Default TTL in seconds |
Uses a Memcached server as the cache backend.
| Option | Type | Default | Description |
|---|---|---|---|
host |
string |
required | Memcached server hostname |
port |
number |
11211 |
Memcached server port |
maxBufferSize |
number |
10 |
Maximum buffer size in MB |
ssl |
boolean | EngineSSLOptions |
— | TLS configuration (true for defaults) |
defaultExpiry |
number |
300 |
Default TTL in seconds |
Uses a Cloudflare Workers KV namespace as the cache backend. KV is eventually
consistent: a delete or clear() can take 60 seconds to reach other data
centres. The engine rejects window mode and any expiry between 1 and 59
seconds. See Cacher-WorkersKV.
| Option | Type | Default | Description |
|---|---|---|---|
binding |
WorkersKVNamespace |
required | The KV namespace from the Worker's env
|
defaultExpiry |
number |
300 |
Default TTL in seconds: 0 (no expiry) or 60–2592000 |
Creates or retrieves a named cache instance for the specified engine.
import { Cacher } from '@tundralibs/cacher';
const cache = Cacher.create('MEMORY', 'my-cache', { defaultExpiry: 300 });The instance name must not contain : — it is the reserved namespace
separator (entries are stored as ${name}:${key}), and a colon in the name
would let one namespace become a prefix of another and break the namespace
isolation guaranteed by clear(). The same rule is enforced by
AbstractEngine, so it also applies when an engine is constructed directly
(e.g. new RedisCacher('user-cache', …)), not just through Cacher.create.
Calling
create()again for an existing name ignoresoptions. The manager returns the already-built instance verbatim — it does not merge, replace, or even read the newoptionsobject (only theenginetype is checked, and a mismatch throws).Cacher.create('MEMORY', 'x', { defaultExpiry: 300 })followed later byCacher.create('MEMORY', 'x', { defaultExpiry: 60 })still has a 300-second default. To rebuild'x'with different options, callawait Cacher.removeInstance('x')first (orawait Cacher.clear()to drop every instance).
Registers a custom cache engine constructor.
import { AbstractEngine, Cacher } from '@tundralibs/cacher';
import type { CacherOptions, CacheValue } from '@tundralibs/cacher/types';
class MyCustomEngine extends AbstractEngine<CacherOptions> {
public readonly Engine = 'CUSTOM';
protected _set(_key: string, _value: CacheValue): void {}
protected _get(_key: string): CacheValue | undefined {
return undefined;
}
protected _has(_key: string): boolean {
return false;
}
protected _delete(_key: string): void {}
protected _clear(): void {}
}
Cacher.addEngine('CUSTOM', MyCustomEngine);
const cache = Cacher.create('CUSTOM', 'custom-cache', { defaultExpiry: 300 });Look up an instance already built by Cacher.create() without constructing a
new one. getInstance returns undefined (not a throw) for an unknown or
invalid name; hasInstance returns false.
import { Cacher } from '@tundralibs/cacher';
Cacher.create('MEMORY', 'lookup-demo', { defaultExpiry: 300 });
if (Cacher.hasInstance('lookup-demo')) {
const cache = Cacher.getInstance('lookup-demo')!;
await cache.set('key', 'value');
}Finalizes (disconnects, for Redis/Memcached) and forgets one named instance,
returning true if it existed. This is the only way to change an existing
name's engine or options — see the callout on Cacher.create() above.
import { Cacher } from '@tundralibs/cacher';
Cacher.create('MEMORY', 'removable', { defaultExpiry: 300 });
const removed = await Cacher.removeInstance('removable');
console.log(removed); // trueList the engine identifiers registered with addEngine (built-in engines
included) and the instance names built with create, both sorted
alphabetically.
import { Cacher } from '@tundralibs/cacher';
Cacher.create('MEMORY', 'inventory-demo', { defaultExpiry: 300 });
console.log(Cacher.getRegisteredEngines()); // ['MEMCACHED', 'MEMORY', 'REDIS', 'WORKERS_KV']
console.log(Cacher.getActiveInstances().includes('inventory-demo')); // trueFinalizes and removes every active instance process-wide — not to be
confused with cache.clear() below, which only empties one instance's
entries and leaves the instance itself registered and usable.
import { Cacher } from '@tundralibs/cacher';
Cacher.create('MEMORY', 'shutdown-demo', { defaultExpiry: 300 });
// Application shutdown: disconnect every Redis/Memcached instance and
// drop all instances from the manager's registry.
await Cacher.clear();
console.log(Cacher.getActiveInstances().length); // 0
Cacher.clear()(manager) vs.cache.clear()(instance).Cacher.clear()tears down and unregisters every instance the manager knows about — after it runs,Cacher.getInstance(name)returnsundefinedfor names that existed a moment ago, and a laterCacher.create()with the same name builds a fresh instance (this time honouring the newoptions, since the old one is gone).cache.clear()only deletes that one instance's data — the instance stays connected and registered.
Stores a value in the cache. The value is JSON-serialized.
| Parameter | Type | Description |
|---|---|---|
key |
string |
Cache key |
value |
T |
Value to cache (must be JSON-serializable) |
options.expiry |
number |
Override TTL in seconds for this entry |
options.window |
boolean |
Extend TTL on each access (sliding expiry) |
expiry accepts fractional seconds, but only the Memory engine honours
sub-second precision (it uses millisecond timers). Redis and Memcached
operate in whole seconds, and they normalise a fractional TTL differently.
Redis rounds it up to the next whole second (e.g. 1.2 → 2s). Memcached
truncates it toward zero (e.g. 1.9 → 1s), except that a positive sub-second
TTL is clamped up to 1s (e.g. 0.2 → 1s) so it is never mistaken for 0
("never expire"). An expiry of 0 always means "never expire" on both.
Retrieves a cached value. Returns undefined if the key does not exist or has expired.
Returns true if the key exists and has not expired.
Removes a single entry from the cache.
Removes all entries in this cache instance's namespace, leaving other
namespaces on the same backend untouched. This is not the same operation
as the manager-level Cacher.clear() documented above — this one only
empties data; the instance itself stays connected and registered.
On Memory and Redis the entries are deleted outright. Redis's
clear() runs KEYS ${name}:* followed by one bulk DEL — see
Cacher-Redis.md for why that is fine
for development but not for a namespace with a large number of keys on a
busy production server. Memcached
has no key enumeration, so the engine keys every entry with a per-namespace
version and clear() bumps that version: prior entries become unreachable
immediately for the clearing instance, then get reclaimed by Memcached's LRU
eviction over time rather than being deleted synchronously. No server-wide
flush_all is issued, so cachers sharing the server are unaffected. Other
instances of the same namespace (e.g. peer processes) pick up the clear within
about a second — each caches the version counter locally but re-reads it on a
short interval, so there is no unbounded window where a peer serves cleared
data or writes invisible keys.
Setting window: true when calling set() enables sliding expiry — the TTL is reset each time the value is accessed with get().
import { Cacher } from '@tundralibs/cacher';
const cache = Cacher.create('MEMORY', 'session-cache', {});
const data = { userId: 42 };
// Entry expires 5 minutes after the last access, not after creation
await cache.set('active-session', data, { expiry: 300, window: true });Extend AbstractEngine to implement a custom backend.
import { AbstractEngine } from '@tundralibs/cacher';
import type { CacherOptions, CacheValue } from '@tundralibs/cacher/types';
class FileEngine extends AbstractEngine<CacherOptions> {
public readonly Engine = 'FILE';
protected async _set(key: string, value: CacheValue): Promise<void> {
// write to disk
}
protected async _get(key: string): Promise<CacheValue | undefined> {
// read from disk
return undefined;
}
protected async _has(key: string): Promise<boolean> {
// check existence
return false;
}
protected async _delete(key: string): Promise<void> {
// remove file
}
protected async _clear(): Promise<void> {
// remove all files for this namespace
}
}import { Cacher } from '@tundralibs/cacher';
import { CacherError } from '@tundralibs/cacher/errors';
try {
const cache = Cacher.create('REDIS', 'my-cache', { host: 'localhost' });
} catch (err) {
if (err instanceof CacherError) {
console.error('Cacher error:', err.message);
}
}| Error Class | When thrown |
|---|---|
CacherError |
Invalid engine name, duplicate registration |
CacherEngineError |
Connection failures, invalid operations or params |
| Feature | Memory | Redis | Memcached |
|---|---|---|---|
| No external dependencies | ✅ | ❌ | ❌ |
| Distributed/shared cache | ❌ | ✅ | ✅ |
| TLS support | ❌ | ✅ | ✅ |
| Window (sliding) expiry | ✅ | ✅ | ✅ |
| Custom TTL per entry | ✅ | ✅ | ✅ |
| Namespace isolation | ✅ | ✅ | ✅ |
MIT