Skip to content
GitHub Actions edited this page Sep 26, 2026 · 11 revisions

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.

JSR JSR Score Deno Bun Node.js Cloudflare Workers Browser

Overview

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.

Browser / Worker compatibility

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.

Modules

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 —

Documentation

Installation

Deno:

deno add @tundralibs/cacher

Bun:

bunx jsr add @tundralibs/cacher

Node.js:

npx jsr add @tundralibs/cacher

Quick Start

In-Memory Cache

import { 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();

Redis Cache

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',
);

Memcached Cache

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 });

Workers KV Cache

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' });

Engines

Memory (MEMORY)

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)

Redis (REDIS)

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

Memcached (MEMCACHED)

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

Workers KV (WORKERS_KV)

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

API Reference

Cacher.create(engine, name, options)

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 ignores options. The manager returns the already-built instance verbatim — it does not merge, replace, or even read the new options object (only the engine type is checked, and a mismatch throws). Cacher.create('MEMORY', 'x', { defaultExpiry: 300 }) followed later by Cacher.create('MEMORY', 'x', { defaultExpiry: 60 }) still has a 300-second default. To rebuild 'x' with different options, call await Cacher.removeInstance('x') first (or await Cacher.clear() to drop every instance).

Cacher.addEngine(name, engine)

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 });

Cacher.getInstance(name) / Cacher.hasInstance(name)

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');
}

Cacher.removeInstance(name)

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); // true

Cacher.getRegisteredEngines() / Cacher.getActiveInstances()

List 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')); // true

Cacher.clear()

Finalizes 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) returns undefined for names that existed a moment ago, and a later Cacher.create() with the same name builds a fresh instance (this time honouring the new options, since the old one is gone). cache.clear() only deletes that one instance's data — the instance stays connected and registered.

cache.set<T>(key, value, options?)

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.

cache.get<T>(key)

Retrieves a cached value. Returns undefined if the key does not exist or has expired.

cache.has(key)

Returns true if the key exists and has not expired.

cache.delete(key)

Removes a single entry from the cache.

cache.clear()

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.

Window Mode

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 });

Custom Engine

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
  }
}

Error Handling

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

Features

Feature Memory Redis Memcached
No external dependencies ✅ ❌ ❌
Distributed/shared cache ❌ ✅ ✅
TLS support ❌ ✅ ✅
Window (sliding) expiry ✅ ✅ ✅
Custom TTL per entry ✅ ✅ ✅
Namespace isolation ✅ ✅ ✅

License

MIT

Clone this wiki locally