@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
| Option | Type | Default | Description |
|---|---|---|---|
store | 'memory' | 'redis' | 'noop' | 'memory' | Backend implementation. |
name | string | 'default' | Instance name for multi-cache setups. |
options | CacheStoreOptions | — | Store-specific configuration. |
diagnostics | CacheDiagnosticsOptions | — | 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
| Export | Kind |
|---|---|
cacheMiddleware | function |
CachePlugin | function |
CacheService | class |
CacheStoreUnavailableError | class |
MemoryStore | class |
NoopStore | class |
RedisStore | class |
CacheDiagnosticsOptions | interface |
CachedResponsePayload | interface |
CacheMiddlewareOptions | interface |
CachePluginOptions | interface |
CacheStoreOptions | interface |
ICacheStore | interface |
IRedisClient | interface |
CacheStoreType | type |
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
| Export | File | Description |
|---|---|---|
CachePlugin | src/plugin/cache-plugin.ts | Plugin factory |
CacheService | src/services/cache-service.ts | Wrapper applying prefix + defaultTTL |
MemoryStore | src/stores/memory-store.ts | In-memory LRU + TTL store |
RedisStore | src/stores/redis-store.ts | Redis store via ioredis |
NoopStore | src/stores/noop-store.ts | No-op store (dev/test) |
cacheMiddleware | src/middleware/cache-middleware.ts | Transparent response-caching middleware |
CacheStoreType | src/interfaces/index.ts | 'memory' | 'redis' | 'noop' |
CacheStoreOptions | src/interfaces/index.ts | Store-specific options |
CachePluginOptions | src/interfaces/index.ts | Plugin factory options |
IRedisClient | src/interfaces/index.ts | Structural ioredis shape |
CacheMiddlewareOptions | src/interfaces/index.ts | Middleware options |
CachedResponsePayload | src/interfaces/index.ts | Cached response shape |
ICacheStore | src/interfaces/index.ts | Re-export from @setu-ts/common |
CacheDiagnosticsOptions | src/interfaces/index.ts | Opt-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.
| Status | Meaning |
|---|---|
up | The store is connected and reachable, or cannot be probed (reachable is 'unknown'). |
down | The 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
| Option | Type | Default | Behavior |
|---|---|---|---|
ttlSeconds | number | store default | Per-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 |
store | string | CAPABILITIES.CACHE | Capability token for the cache store to use |
cacheableStatuses | number[] | [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>;
}