@setu-ts/cache-plugin

Caching with pluggable stores. Registers an ICacheStore under CAPABILITIES.CACHE ('cache').

Three stores ship: MemoryStore (LRU with per-entry TTL, zero-dependency default), RedisStore (over ioredis, lazily imported or injected), and NoopStore.

Installation

import { CachePlugin } from '@setu-ts/cache-plugin';

Usage

import { createApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { cacheMiddleware, CachePlugin, CacheService } from '@setu-ts/cache-plugin';
import { CAPABILITIES, type ICacheStore } from '@setu-ts/common';

const app = createApplication({
  plugins: [
    RuntimePlugin(),
    CachePlugin({ store: 'redis', options: { url: 'redis://localhost:6379' } }),
  ],
});
await app.start({ port: 3000 });

const cache = app.services.get<ICacheStore>(CAPABILITIES.CACHE);
await cache.set('user:1', { name: 'Ada' }, 60);
const user = await cache.get<{ name: string }>('user:1');

// CacheService adds coalesced read-through for one cache key.
const readThroughCache = app.services.get<CacheService>(CAPABILITIES.CACHE);
const profile = await readThroughCache.getOrSet(
  'profile:1',
  async () => {
    return await loadProfile('1');
  },
  60,
);

Options

OptionTypeDefaultDescription
store'memory' | 'redis' | 'noop''memory'Backend implementation.
namestring'default'Instance name for multi-cache setups.
optionsCacheStoreOptions—Store-specific configuration.
diagnosticsCacheDiagnosticsOptions—Opt-in operation counters (M98i).

With store: 'redis', connection errors of the ioredis client the store builds go to the application logger — the first error of an outage at warn, identical repeats at debug, the recovery at info — instead of ioredis printing every reconnect failure to console.error. An injected client gets no listener: it belongs to the caller.

node-redis. To use node-redis instead of ioredis, pass options: { client: fromNodeRedis(createClient({ url })) } (fromNodeRedis from @setu-ts/common, createClient from redis, installed in your application). The adapter bounds its own commands (fromNodeRedis(client, { commandTimeoutMs })); this store’s commandTimeoutMs applies only to the client it builds. See Using node-redis.

options.commandTimeoutMs (Redis only, default 15000, 0 disables) bounds each command on the client the store builds. A paused or partitioned server keeps its socket open, so without the bound a cache call waits forever; with it the call rejects and the diagnostics count it failed. (The cache health indicator reports such a server down on its own, independent of this option: its ping() probe has a fixed 2-second bound.) It is not applied to an injected client, and a value outside 0–2147483647 throws RangeError when CachePlugin(...) is called.

A name other than 'default' derives the capability token as cache.<name>, so several caches can coexist in one application.

Diagnostics (M98i)

CachePlugin({ diagnostics: { enabled: true, alias: 'primary' } }) counts every backend call this instance’s own CacheService makes (get, set, delete, has, clear; getOrSet counts as its internal get/set) — succeeded, failed, hits, misses, present/absent and removed/notRemoved — for the local diagnostics connector’s GET /v1/cache (@setu-ts/diagnostics-plugin). Keys, prefixes, values, Redis URLs, factory results and errors are never captured, and no eviction is inferred. Without the option the instance registers an inert disabled source and its cache calls are not touched. Results, errors, TTL, prefix and getOrSet coalescing are identical either way; enabled calls add one clock read (two on the timed one-in-eight sample) and one derived promise each (which re-rejects with the original reason, so an unhandled rejection stays unhandled), measured at about 5% of throughput against a real Redis at 50 concurrent calls. Direct store calls and a replacement service are outside coverage. Enable only on an approved development dataset: counts aggregate every tenant using the instance.

Response caching

cacheMiddleware(options) transparently caches responses. Streaming responses are skipped — a live ReadableStream cannot be replayed from a cache, so those requests are marked X-Cache: MISS and pass straight through.

Concurrent misses for the same key are coalesced within one process: one request reaches a cacheable, buffered origin response and waiters replay it with X-Cache: COALESCED. A failed, streaming, or otherwise uncacheable leader is not replayed; every waiter runs the origin itself. Separate processes do not coordinate, so one overlapping successful batch can produce at most one origin call per process. CacheService.getOrSet() applies the same per-store coalescing to programmatic read-through calls.

Exports

ExportKind
cacheMiddlewarefunction
CachePluginfunction
CacheServiceclass
CacheStoreUnavailableErrorclass
MemoryStoreclass
NoopStoreclass
RedisStoreclass
CacheDiagnosticsOptionsinterface
CachedResponsePayloadinterface
CacheMiddlewareOptionsinterface
CachePluginOptionsinterface
CacheStoreOptionsinterface
ICacheStoreinterface
IRedisClientinterface
CacheStoreTypetype

Generated from the package barrel by pnpm run docs:exports; pnpm run check:docs fails when it drifts.

Full API

Every export and option is documented in PUBLIC_API.md.

Readiness and dependency outages (M113b)

RedisStore.get/set/delete/has/clear wrap recognised connection faults in CacheStoreUnavailableError, preserving the original as cause. With the exceptions errorHandler, this returns 503 and the fixed detail The cache is temporarily unavailable. Other errors propagate unchanged. Inspect cause for driver diagnostics.

API reference

CachePlugin() (@setu-ts/cache-plugin)

RedisStore.get/set/delete/has/clear wrap recognised connection faults in CacheStoreUnavailableError, preserving the original as cause. With the exceptions errorHandler, this returns 503 and the fixed detail The cache is temporarily unavailable. Other errors propagate unchanged. Inspect cause for driver diagnostics.

Provides caching with multiple stores (Memory, Redis, Noop) and a transparent response-caching middleware. CacheService.getOrSet() offers coalesced programmatic read-through for callers that resolve the concrete exported service.

Registers ICacheStore under CAPABILITIES.CACHE.

Exports

ExportFileDescription
CachePluginsrc/plugin/cache-plugin.tsPlugin factory
CacheServicesrc/services/cache-service.tsWrapper applying prefix + defaultTTL
MemoryStoresrc/stores/memory-store.tsIn-memory LRU + TTL store
RedisStoresrc/stores/redis-store.tsRedis store via ioredis
NoopStoresrc/stores/noop-store.tsNo-op store (dev/test)
cacheMiddlewaresrc/middleware/cache-middleware.tsTransparent response-caching middleware
CacheStoreTypesrc/interfaces/index.ts'memory' | 'redis' | 'noop'
CacheStoreOptionssrc/interfaces/index.tsStore-specific options
CachePluginOptionssrc/interfaces/index.tsPlugin factory options
IRedisClientsrc/interfaces/index.tsStructural ioredis shape
CacheMiddlewareOptionssrc/interfaces/index.tsMiddleware options
CachedResponsePayloadsrc/interfaces/index.tsCached response shape
ICacheStoresrc/interfaces/index.tsRe-export from @setu-ts/common
CacheDiagnosticsOptionssrc/interfaces/index.tsOpt-in cache operation counters (M98i)

Registration

import { CachePlugin } from '@setu-ts/cache-plugin';

// Memory store (default)
app.register(CachePlugin());

// Redis store with URL
app.register(
  CachePlugin({
    store: 'redis',
    options: { url: 'redis://localhost:6379', prefix: 'myapp:' },
  }),
);

// Named multi-cache instance
app.register(CachePlugin({ name: 'session', options: { maxSize: 500 } }));

Programmatic API

import type { ICacheStore } from '@setu-ts/common';
import { CacheService } from '@setu-ts/cache-plugin';

app.router.get('/users/:id', async (ctx) => {
  const cache = ctx.services.get<ICacheStore>('cache');
  const cacheKey = `user:${ctx.params.id}`;

  // Try cache
  const cached = await cache.get<User>(cacheKey);
  if (cached) {
    return ctx.response.json(cached);
  }

  // Fetch from database
  const user = await getUser(ctx.params.id);

  // Cache for 1 hour
  await cache.set(cacheKey, user, 3600);

  return ctx.response.json(user);
});

For a programmatic read-through, resolve the exported CacheService and use getOrSet(). Concurrent cold reads for the same key share one factory invocation; a rejected leader leaves no in-flight entry, so each joined caller retries its own factory.

const cache = app.services.get<CacheService>('cache');
const user = await cache.getOrSet(`user:${id}`, async () => await getUser(id), 3600);

Health status

Since M90b the indicator reports two signals, and no longer conflates them: the store’s lifecycle (isReady()) and its reachability. The Redis store probes with a typed ping(); the memory and no-op stores report their live lifecycle truth (an in-process store has no separate backend to reach). The probe is cached for 5 seconds and bounded at 2 seconds through createCachedProbe, so polling /health never turns into backend load.

StatusMeaning
upThe store is connected and reachable, or cannot be probed (reachable is 'unknown').
downThe store is not connected, or is connected but unreachable.

data reports { store, name, reachable }, where reachable is true, false, or 'unknown' when the store offers no probe. A store that cannot probe is never reported as reachable — the absent capability stays explicitly 'unknown'.

Connection errors. The Redis store routes the 'error' events of the ioredis client it builds to the application logger through createConnectionErrorReporter (common): the first error of an outage at warn, identical repeats at debug, the recovery at info. Without a listener ioredis prints every reconnect failure to console.error, bypassing the logger and redaction. An injected client gets no listener — it belongs to the caller. Health semantics are unchanged; the indicator still reports the outage.

Command bound. Since M101a the Redis store’s options.commandTimeoutMs (default 15000, 0 disables) is the ioredis commandTimeout on the client it builds. A paused or partitioned Redis keeps its socket open, so a command used to wait forever; it now rejects inside the bound, is counted failed in the cache observations, and the cache indicator reports down. Not applied to an injected client. A value outside 0–2147483647 throws RangeError when CachePlugin(...) is called.

Cache diagnostics (M98i)

CachePlugin({ diagnostics: { enabled: true, alias: 'primary' } }) counts every backend call its OWN CacheService makes — get, set, delete, has, clear, with getOrSet counted as its internal get/set calls — for the local diagnostics connector’s GET /v1/cache. enabled must be the literal true; alias is 1–64 UTF-8 bytes with no control character; any other key is refused. All three refuse when CachePlugin(...) is called, with fixed messages that never echo a value.

Every instance, opted in or not, registers an ICacheDiagnosticsSource under CAPABILITIES.CACHE_DIAGNOSTICS with { multi: true } (not in provides); without the option it answers disabled and nothing is attached to the service — no clock read and no extra promise on any cache call. With it, each call reads the runtime monotonic clock once (and a second time on the first call per operation and one in every eight after it, which are the timed calls) and returns a promise derived from the backend’s that re-rejects with the original reason, so an unhandled backend rejection stays unhandled; results, null semantics, TTL, prefix, getOrSet coalescing and the ORIGINAL rejection reason are unchanged, and a synchronous backend throw is still thrown synchronously. A null get, a false has and a false delete are successful calls (miss / absent / notRemoved); a rejection is failed and never a collection failure. Records expire 60 seconds after their last observation and a snapshot whose freshest record is older than 30 seconds is stale. lastDurationMs is the most recent TIMED call’s duration (null when none in the retention window was timed). No eviction is ever counted or inferred. A clock failure latches collection-failed without changing any application result; closing the plugin detaches the collector before clearing it, and the source then answers disabled. Keys, prefixes, values, URLs, factory results and errors are never captured. Direct store calls and a CacheService constructed or registered by application code are outside coverage.

Cache Middleware

Transparent response-caching middleware that stores full HTTP responses (status, headers, body) and replays them on cache HIT without invoking the handler. Concurrent misses for one key coalesce within one process: the leader answers X-Cache: MISS and waiters replay a cacheable buffered response with X-Cache: COALESCED. A failed, streaming, or uncacheable leader is never replayed, so each waiter runs the origin itself. Multiple processes do not coordinate; an overlapping successful batch produces at most one origin call per process.

import { cacheMiddleware } from '@setu-ts/cache-plugin';

app.router.get('/users/:id', {
  middleware: [
    cacheMiddleware({
      ttlSeconds: 3600,
      key: (ctx) => `user:${ctx.params.id}`,
      bypass: (ctx) => ctx.request.query.refresh === 'true',
    }),
  ],
  handler: async (ctx) => {
    const user = await getUser(ctx.params.id);
    return ctx.response.json(user);
  },
});

Options

OptionTypeDefaultBehavior
ttlSecondsnumberstore defaultPer-route TTL override in seconds; when omitted the store’s defaultTtl applies
key(ctx) => string${method}:${url}Custom cache key generator. The tenant and locale discriminator segments are composed around this key too, so a tenant-aware application stores one entry per tenant even when a custom key is supplied
vary(ctx) => readonly string[]—Per-request discriminator values appended to the key after the tenant and locale segments. Each returned string is length-prefixed and joined in order, so two requests differing in any value never share an entry; omitted leaves the key unchanged
bypass(ctx) => boolean—When true, skip caching entirely for this request and pass through to the handler
storestringCAPABILITIES.CACHECapability token for the cache store to use
cacheableStatusesnumber[][200]HTTP status codes eligible for caching

Key composition

The key is tenant segment + locale segment + vary segment + base key. The tenant segment comes from ctx.request.tenant?.id and the locale segment (M103) from ctx.request.locale, each length-prefixed and empty when absent — so an application with neither multi-tenancy nor localization keeps byte-identical keys. Both segments are read when cacheMiddleware runs: a route-level cacheMiddleware runs after every global middleware and sees whatever global middleware set, while a GLOBAL registration must have a higher priority number than the middleware that sets each one — the tenant middleware (40) and the locale middleware (45 by default, or the configured middleware.priority). Where the locale middleware is applied per route instead (middleware.enabled: false), a global cache runs before it, so the cache belongs on that route, listed after localeMiddleware. A replaceLocale override is reflected only when it runs before the cache lookup; a route that changes the locale inside its handler must not be response-cached. The segments protect this plugin’s own store only — a shared cache or CDN relies on the Vary header the localization middleware writes.

ICacheStore Interface

interface ICacheStore {
  get<T>(key: string): Promise<T | null>;
  set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<boolean>;
  has(key: string): Promise<boolean>;
  clear(): Promise<void>;
}

Agent card · TypeDoc API reference