cache-envelop
v4.0.1
Published
Wrapper for working with caching services (Memcached, Redis)
Maintainers
Readme
Wrapper for working with caching services (Memcached, Redis)
Content
About Install Portable core Connection configs Connecting Redis Connecting Memcached In-process cache Memcached API reference TypeScript Error handling Known limitations Testing Changelog
About Services often use Memcached and Redis at the same time. This package is a helper wrapper to make it easier to work with them.
All three wrappers — Memcached, Redis and the dependency-free in-process
Memory — implement the same portable core: get, set,
del, close. Plain key/value code reads the same whichever backend is behind it.
On top of that core, the Memcached wrapper simulates data types Memcached does not have, so hashes and lists are available there too:
// portable core — identical on all three backends
get(key),
set(key, data, ttl),
del(key),
close(),
// Memcached only: simulated hash and list types
hashSet(key, field, data, ttl),
hashGet(key, field),
hashDel(key, field, options),
listSet(key, data, ttl, options = {}),
listGet(key, options = {}),
listDel(key, ttl, options = {})For hashes, lists and every other native structure on the Redis side, use the raw client:
redis.client.hset(...). See Known limitations.
Install
npm install cache-envelopThe client packages are optional peer dependencies, so install the one you actually use:
npm install cache-envelop ioredis # Redis (or Valkey, Dragonfly, …)
npm install cache-envelop memcached # Memcached
npm install cache-envelop # Memory only — nothing else neededRequiring the package pulls in neither client; each is loaded when its wrapper is first constructed. Build a wrapper whose client is missing and you get a message saying which package to install, rather than a crash on import that hits everyone regardless of the backend they use:
cache-envelop: RedisWrapper requires the "ioredis" package, which is not installed.
Install it with `npm install ioredis`.Portable core
Memcached, Redis and Memory implement four methods with identical, test-enforced behavior:
| Method | Contract |
| --- | --- |
| get(key) | The stored value, or undefined if the key does not exist. A stored null comes back as null, so it stays distinguishable from a miss. |
| set(key, data, ttl) | Stores data for ttl seconds. ttl is required; 0 means no expiration. |
| del(key) | Resolves to 'OK', whether or not the key existed. |
| close() | Releases the backend: closes the connection, or drops every entry for Memory. Awaitable on all three, though only Redis actually returns a promise. |
Keys may be a string or a number. Values survive the round trip unchanged — strings, numbers,
booleans, null, objects and arrays all come back as they went in, on all three backends.
const { Memcached, Redis } = require('cache-envelop');
// The same function works with either one.
async function cacheUser(cache, user) {
await cache.set(`user:${user.id}`, user, 3600);
return cache.get(`user:${user.id}`);
}
await cacheUser(new Memcached('127.0.0.1:11211'), user);
await cacheUser(new Redis('127.0.0.1:6379'), user);
await cacheUser(new Memory(), user); // no server neededThe same validation errors are raised by all three: a missing/blank key, a key that is neither a string
nor a number, undefined data, and a missing/non-numeric/negative ttl.
Two things stay backend-specific by design:
- Key rules. Redis and
Memoryaccept almost any key; Memcached's text protocol does not (250 characters, no whitespace). The first two therefore enforce only the portable minimum unless you opt in withstrictKeys: true. - Value storage. The Redis wrapper JSON-encodes on write and decodes on read, which makes it
the owner of the stored format: keys written by something else are readable through
.client, not throughget.
Connection configs
// config/default.js
module.exports = {
redis: {
port: 6379,
host: "127.0.0.1",
username: "redisUser",
password: "redis$password",
db: 0, // Defaults to 0
},
memcached: {
servers: [ '127.0.0.1:11211', '127.0.0.1:11212', '127.0.0.1:11213' ],
options: {
retries: 5,
retry: 5000,
remove:true,
failOverServers:['127.0.0.1:11214', '127.0.0.1:11215']
}
}
};Connecting Redis
// redisConnect.js
const config = require('config')
const { Redis } = require('cache-envelop');
const redis = new Redis(config.redis);
await redis.set('user:1', { name: 'Alice' }, 3600); // portable core
await redis.client.hset('user:1:meta', 'seen', Date.now()); // native ioredis- Supports all possible formats of connection options that it supports npm package ioredis
- Implements the portable core:
get,set,del,close - All methods and arguments implemented in the npm package ioredis
remain available through
.client— the wrapper adds to ioredis, it does not hide it setsends a whole-secondttlasEXand a fractional one asPX, so attlof1.5is 1500 ms rather than being truncated to 1 second.ttl: 0is sent as a plainSET, sinceSET key value EX 0is an error in Redis.- Values are JSON-encoded on write and decoded on read, so objects survive the round trip.
A value that JSON cannot represent (a function, a symbol, a circular structure) throws a
descriptive error instead of being silently stored as
'[object Object]'. - Passing an empty/blank connection string (or
null) throws immediately instead of silently falling back to127.0.0.1:6379— a misconfigured connection should fail loudly, not connect to the wrong host. Omitting the argument entirely still uses that default. - A second, optional argument configures wrapper-level behavior:
const redis = new Redis(config.redis, { onError: (err) => logger.error('Redis connection error', err), strictKeys: true, });onError— see Error handling for why this matters.strictKeysapplies Memcached's key rules (no whitespace, 250 characters max) to this client too. Redis itself has no such limits, so it is off by default; turn it on to catch keys that would not survive a switch to the Memcached backend.
Connecting Memcached
// memcachedConnect.js
const config = require('config')
const { Memcached } = require('cache-envelop');
const memcached = new Memcached(config.memcached.servers, {
...config.memcached.options,
onIssue: (details) => logger.warn('Memcached connection issue', details),
});- Supports all possible formats of connection options that it supports npm package memcached
- Implements the portable core:
get,set,del,close— all asynchronous - Keys may be a
stringor anumberand are validated against Memcached's own protocol constraints: at most 250 characters, and no whitespace anywhere (the text protocol is space-delimited, so the server rejects'user 1'itself). Empty, whitespace-containing and over-length keys throw a descriptiveErrorsynchronously — the length is measured on the key as it will actually be sent, not on a whitespace-stripped copy. - Every method that writes a key requires a non-negative numeric
ttl—set,hashSet,listSet, andhashDel/listDelwhen they rewrite the remainder of a hash/list. Usettl: 0for "no expiration" (standard Memcached semantics), neverundefined: silently reusing a default would reset the expiration of an existing entry behind the caller's back. - The
optionsobject oflistSet/listGet/listDelis validated, not best-effort: unknown keys and negative/fractional/non-numeric positions throw. A typo such as{ idx: 0 }used to fall through to "no position given" and wipe the entire list. - Implemented simulation of working with hashes and lists — see the Memcached API reference below for exact signatures and behavior.
In-process cache
Memory implements the portable core with a Map. No server, no connection
string, no dependencies — so the same code you ship can run in tests and local development
without Docker:
const { Memory } = require('cache-envelop');
const cache = new Memory();
await cache.set('user:1', { name: 'Alice' }, 3600);
await cache.get('user:1'); // { name: 'Alice' }- Values are copied, not shared. They go through the same JSON encoding as the other backends
instead of being stored by reference. That costs a copy, and it is the point: a value that
survives here survives in production, a value JSON cannot represent fails here the same way it
would there, and mutating the object you passed in — or the one
gethanded back — cannot reach into the cache. - Expiration is lazy. An entry is dropped when a read finds it past its deadline; there are no
timers to leak or to keep the event loop alive. A key written and never read again therefore
holds its memory until eviction or
close(). maxKeysbounds the store (default0, unbounded). Once exceeded, the least recently written entry is dropped. This is write-order eviction, not an LRU — reads do not make a key any safer. For a hot L1 cache that needs real LRU, use a dedicated library.close()drops every entry; there is no connection to tear down.sizereports how many entries are held, including any that have expired but not yet been read.
const cache = new Memory({ maxKeys: 10_000, strictKeys: true });strictKeys applies Memcached's key rules, which is worth turning on in tests: it catches keys
that would work here but fail against a real Memcached server.
Memcached API reference
| Method | Description |
| --- | --- |
| get(key) | Returns the raw stored value, or undefined if the key does not exist. |
| set(key, data, ttl) | Stores data under key for ttl seconds (0 = no expiration). ttl is required. |
| del(key) | Deletes key. Always resolves to 'OK'. |
| hashSet(key, field, data, ttl) | Sets/updates a single field in the hash stored at key, creating the hash if it doesn't exist yet, and rewrites it with the given ttl (required). |
| hashGet(key, [field]) | Returns the whole hash, or a single field's value. Returns undefined if the key (or a JSON-null hash) doesn't exist. |
| hashDel(key, [field], [options]) | Without field, deletes the whole key. With field, removes just that field and rewrites the hash using options.ttl (required in that case — omitting it throws a validation error rather than silently corrupting the TTL). Returns 'Data not found' if the key or field doesn't exist. |
| listSet(key, data, ttl, [options]) | Inserts data into the list at key. ttl is required. options.index (a non-negative integer, 0 included) overwrites that slot; options.push: true appends; otherwise the default is unshift (prepend). Any other option key, or a negative/fractional/non-numeric index, throws. |
| listGet(key, [options]) | Without options, returns the full list (or undefined). options.index returns one item; options.start/options.end (inclusive) return a slice. All three must be non-negative integers (0 included); unknown option keys throw. |
| listDel(key, ttl, [options]) | Without options, deletes the whole key (no ttl needed). With options.index or options.start/options.end, removes just that item/range and rewrites the list with ttl (required in that case). Unknown option keys throw instead of clearing the list. Resolves to undefined if the stored list is empty. |
await memcached.hashSet('user:1', 'name', 'Alice', 3600);
await memcached.hashSet('user:1', 'age', 30, 3600);
await memcached.hashGet('user:1'); // { name: 'Alice', age: 30 }
await memcached.hashGet('user:1', 'age'); // 30
await memcached.listSet('queue', 'first', 3600, { push: true });
await memcached.listSet('queue', 'second', 3600, { push: true });
await memcached.listGet('queue', { index: 0 }); // 'first' — index 0 works as expected
await memcached.listGet('queue', { start: 0, end: 0 }); // ['first']
// Validated, not best-effort — these throw instead of doing something surprising:
await memcached.get('user 1'); // key contains whitespace
await memcached.listSet('queue', 'x', 3600, { index: -1 }); // index must be >= 0
await memcached.listDel('queue', 3600, { idx: 0 }); // unknown option (used to clear the list)
await memcached.listSet('queue', 'x'); // ttl is requiredTypeScript
The package ships its own declarations (index.d.ts), so no @types/... install is needed:
import { Memcached, Redis } from 'cache-envelop';
const redis = new Redis({ host: '127.0.0.1', port: 6379 });
await redis.client.get('key'); // full ioredis typings via `.client`
const memcached = new Memcached(['127.0.0.1:11211'], { retries: 5 });
await memcached.hashSet('user:1', 'name', 'Alice', 3600);
const name = await memcached.hashGet('user:1', 'name'); // unknown — narrow it yourselfCacheCore is exported for code that should not care which backend it gets:
import { CacheCore } from 'cache-envelop';
async function cacheUser(cache: CacheCore, user: User): Promise<unknown> {
await cache.set(`user:${user.id}`, user, 3600);
return cache.get(`user:${user.id}`);
}
await cacheUser(memcached, user); // all three compile
await cacheUser(redis, user);
await cacheUser(new Memory(), user);All three classes are declared implements CacheCore, so they cannot drift apart without
npm run typecheck failing.
One caveat for the Memory-only case: index.d.ts imports ioredis's types to give .client its
full typing, and TypeScript resolves that import even if you never touch the Redis class. With
skipLibCheck: true — the default from tsc --init, and what most projects run — this is a
non-issue. With skipLibCheck: false and ioredis absent you will see
TS2307: Cannot find module 'ioredis'; install ioredis (or add @types resolution for it) to
silence it. The alternative would be to type .client loosely for everyone, which costs more than
it saves.
npm run typecheck compiles the declarations against a usage fixture
(test/types/usage.ts) in CI, so the published types cannot drift from the implementation.
The package declares engines: { node: ">=20" }, matching the Node versions CI tests.
Error handling
- Redis:
ioredisclients areEventEmitters — anerrorevent with no listener attached crashes the whole Node.js process with an uncaught exception.RedisWrapperalways attaches a listener (aconsole.errorby default) so a dropped connection degrades instead of taking the app down. Pass your ownonError(see Connecting Redis) to route errors to your logger/metrics instead. - Memcached: the
memcachedpackage does not emit an unhandled top-levelerrorthe wayioredisdoes, but it silently emitsissue/failure/reconnecting/removeevents that are easy to miss.MemcachedWrapperlistens to all four (console.errorby default, overridable viaonIssue) so connectivity problems are visible instead of silent. - Memory: there is no connection, so there are no connection errors — one reason it is the easiest backend to write tests against.
- Stored values are JSON — everything written by
RedisandMemory, and everything the Memcached hash/list helpers put in a key. If a value cannot be read back (it was written by something other than this wrapper, or the entry is corrupted),getand the helpers reject with a clearFailed to parse cached value as JSON: ...rather than a rawSyntaxError. Writing a value JSON cannot represent — a function, a symbol, a circular structure — fails on the way in, withThe data to cache must be JSON-serializableorFailed to serialize the value as JSON, instead of storing something broken.
Known limitations
- The Memcached hash/list helpers (
hashSet,hashDel,listSet,listDel) are implemented as a read-modify-write cycle (getthe JSON blob, mutate it,setit back) because plain Memcached has no native hash/list commands. This is not atomic: concurrent writers to the same key can race and one update can be lost. Fine for low-contention use (per-request caches, mostly-read data); if you need strong consistency under concurrent writers on the same key, use Redis (or a server-side lock) instead. - The wrappers are interchangeable only for the portable core
(
get/set/del/close). Beyond it they intentionally differ:MemcachedWrapperadds simulated hashes and lists, whileRedisWrapperexposes the rawioredisclient rather than re-implementing every Redis command. Redis has nativeHSET/LPUSH/LRANGEthat are atomic and faster than anything an emulation layer could offer, so wrapping them would trade a real advantage for cosmetic symmetry. - Even inside the core, the Redis wrapper's
get/setown the stored format (JSON). Reading a key written by another service, or byredis.client.set(...)directly, must go through.client. Memoryis a cache for tests, local development and single-process use — not a shared one. Its entries live in one process and are lost when it exits, expiration is lazy (a key written and never read again holds its memory until eviction orclose()), andmaxKeysevicts by write order rather than as an LRU. It is also unbounded unless you setmaxKeys.
Testing
npm test # unit suite, no servers needed
npm run test:coverage # with a coverage report (100% lines/branches/functions/statements)
npm run lint # eslint (airbnb-base)
npm run typecheck # compile index.d.ts against test/types/usage.ts
npm run test:integration # the same contract against live servers (see below)
npm run test:smoke # pack, install with no peers, check the package worksThe suite runs fully offline: __mocks__/ioredis.js and __mocks__/memcached.js are small
in-memory stand-ins for the real clients (get/set/del backed by a Map, plus the ability to force
the next call to fail), so no live Redis/Memcached server is required to run or contribute to the
tests. The ioredis mock also reproduces the server-side errors the wrapper has to design around —
SET key value EX 0, a fractional EX, an unknown SET modifier — so a test cannot pass against
the mock and fail against a real server.
The contract itself lives once, in test/support/coreContractScenario.js. test/coreContract.test.js
runs it against all three wrappers on the mocks, and test/integration/ runs the very same
assertions against live Redis, Dragonfly and Memcached servers. A contract that drifts between
its fast run and its realistic one is not a contract. Memory matters most in the fast run: being
a completely different implementation, it is the one that would expose the core as a description of
ioredis rather than a real abstraction.
Integration backends are opt-in through environment variables, so a run degrades to whatever is reachable:
docker run -d -p 16379:6379 redis:7-alpine
docker run -d -p 11211:11211 memcached:1.6-alpine
docker run -d -p 16380:6379 --ulimit memlock=-1 docker.dragonflydb.io/dragonflydb/dragonfly
INTEGRATION_REDIS_URL=redis://127.0.0.1:16379 \
INTEGRATION_DRAGONFLY_URL=redis://127.0.0.1:16380 \
INTEGRATION_MEMCACHED=127.0.0.1:11211 \
npm run test:integrationThat run is also how Dragonfly support is verified rather than assumed: it is
exercised through the ordinary Redis wrapper, because Dragonfly speaks the Redis protocol.
One trap worth knowing if you extend the integration suite: Jest substitutes __mocks__/<pkg>.js
for a node_modules package automatically, with no jest.mock() call anywhere. The
integration file therefore calls jest.unmock('ioredis') and jest.unmock('memcached'), and
asserts that the loaded packages are not the mocks. Without that guard the suite passes in
milliseconds while the servers sit idle — which is exactly what it did before the guard was added.
npm run test:smoke packs the tarball npm would publish, installs it into a throwaway project with
--omit=peer, and checks that require('cache-envelop') works, that Memory is usable, and that
each missing client reports how to install itself. Only a real install can show that files, the
lazy requires and peerDependenciesMeta line up.
Changelog
4.0.0 (2026-08-31) — major, contains the breaking changes described below:
- Breaking:
ioredisandmemcachedmoved fromdependenciesto optionalpeerDependencies. Installing this package no longer installs either client, so a project using only Redis stops dragging in a Memcached client and vice versa — and theMemorybackend needs neither. Add the client you use to your own dependencies:npm install ioredisornpm install memcached. Each is nowrequired when its wrapper is constructed rather than at import time, so a missing client raises a message naming the package to install instead of breakingrequire('cache-envelop')for everyone. - Breaking: removed
redis.client.close(). The wrapper used to patch aclosealias onto the ioredis instance, which predates it having an API of its own; monkey-patching a third-party object also risks colliding with whatever ioredis adds later. Callredis.close()instead — it is unchanged, and.clientis now exactly the ioredis instance with nothing bolted on.
Additive in the same release:
- New
Memorybackend: an in-process, dependency-free implementation of the portable core for tests and local development. Values are JSON-copied rather than stored by reference, expiration is lazy, andmaxKeysbounds the store by write order. - The core contract now also runs against live Redis, Dragonfly and Memcached servers
(
npm run test:integration), not only against mocks, with a new CI job supplying all three. Dragonfly is exercised through the ordinaryRediswrapper, since it speaks the Redis protocol. npm run test:smokepacks the publishable tarball, installs it with no peer dependencies, and checks thatrequire,Memoryand both missing-client messages behave. It runs in CI.
3.1.0 (2026-08-31) — minor, additive only:
Redisimplements the portable core —get,set,del— alongsideclose, with the same contracts, validation and error messages asMemcached..clientis untouched, so nothing that used the raw ioredis instance changes.Redis#setsends a fractionalttlasPXrather than truncating it, andttl: 0as a plainSET(EX 0is an error in Redis). Values are JSON-encoded on write and decoded on read.- New
RedisoptionstrictKeys: trueapplies Memcached's key rules to a Redis client, to catch keys that would not survive a backend switch. - New exported type
CacheCore; both classes are declaredimplements CacheCore. - Validation moved to
src/validators.jsso both backends enforce one contract from one place. - New
test/coreContract.test.jsruns one scenario against both backends.
3.0.0 (2026-08-31) — major, contains breaking behavior changes described below:
Memcached: keys containing whitespace are now rejected up front. Memcached's text protocol is space-delimited and the server rejects such keys itself, so they used to fail only at runtime.Memcached: the 250-character key limit is now measured on the raw key. Whitespace was stripped before the check, so a 349-character key with spaces in it passed validation.Memcached#listSet/listGet/listDel:optionsis validated. Unknown keys and negative / fractional / non-numericindex/start/endnow throw. Previously a typo likelistDel(key, ttl, { idx: 0 })fell through to the "no options" branch and cleared the whole list, a negativeindexsilently became anunshiftinlistSet, andlistGetaccepted a negative index thatlistSetrejected.Memcached#listSet/listDel/hashSet:ttlis validated up front and is documented as required, matchingsetandhashDel. It was already effectively required (the internalsetthrew), but JSDoc marked it optional and the error surfaced only after a needless cache read.- Added TypeScript declarations (
index.d.ts), anenginesfield (node >= 20), and anpm run typecheckstep in CI.
2.0.0 (2026-07-31) — major, contains breaking behavior changes described below:
Memcached: numeric keys (documented as supported) no longer throw aTypeError.Memcached#set:ttl: 0is now accepted (means "no expiration"); previously it was rejected the same as a missing ttl.Memcached#listSet/listGet/listDel:index/start/endof0are now handled correctly instead of being treated as "not provided".Memcached#hashDel(key, field)(withoutoptions) no longer throws a rawTypeError; it now throws a descriptive validation error ifoptions.ttlis missing.Memcached#hashGet(key, field)on a missing key now returnsundefinedinstead of throwing.Redis/Memcached: passing an empty/blank connection string now throws instead of silently connecting to127.0.0.1.Redis/Memcached: connection-level errors no longer go unhandled (see Error handling).
Also new in this release (non-breaking): a full Jest test suite with 100% coverage of src/, and
a GitHub Actions CI workflow that runs lint + the test suite on Node 20/22/24 for every push and
pull request against main.
