npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

cache-envelop

v4.0.1

Published

Wrapper for working with caching services (Memcached, Redis)

Readme

Wrapper for working with caching services (Memcached, Redis)

CI npm version

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

The 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 needed

Requiring 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 needed

The 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 Memory accept 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 with strictKeys: 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 through get.

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
  • set sends a whole-second ttl as EX and a fractional one as PX, so a ttl of 1.5 is 1500 ms rather than being truncated to 1 second. ttl: 0 is sent as a plain SET, since SET key value EX 0 is 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 to 127.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. strictKeys applies 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 string or a number and 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 descriptive Error synchronously — 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 ttlset, hashSet, listSet, and hashDel/listDel when they rewrite the remainder of a hash/list. Use ttl: 0 for "no expiration" (standard Memcached semantics), never undefined: silently reusing a default would reset the expiration of an existing entry behind the caller's back.
  • The options object of listSet/listGet/listDel is 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 get handed 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().
  • maxKeys bounds the store (default 0, 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.
  • size reports 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 required

TypeScript

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 yourself

CacheCore 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: ioredis clients are EventEmitters — an error event with no listener attached crashes the whole Node.js process with an uncaught exception. RedisWrapper always attaches a listener (a console.error by default) so a dropped connection degrades instead of taking the app down. Pass your own onError (see Connecting Redis) to route errors to your logger/metrics instead.
  • Memcached: the memcached package does not emit an unhandled top-level error the way ioredis does, but it silently emits issue / failure / reconnecting / remove events that are easy to miss. MemcachedWrapper listens to all four (console.error by default, overridable via onIssue) 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 Redis and Memory, 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), get and the helpers reject with a clear Failed to parse cached value as JSON: ... rather than a raw SyntaxError. Writing a value JSON cannot represent — a function, a symbol, a circular structure — fails on the way in, with The data to cache must be JSON-serializable or Failed 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 (get the JSON blob, mutate it, set it 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: MemcachedWrapper adds simulated hashes and lists, while RedisWrapper exposes the raw ioredis client rather than re-implementing every Redis command. Redis has native HSET/LPUSH/LRANGE that 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/set own the stored format (JSON). Reading a key written by another service, or by redis.client.set(...) directly, must go through .client.
  • Memory is 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 or close()), and maxKeys evicts by write order rather than as an LRU. It is also unbounded unless you set maxKeys.

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 works

The 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:integration

That 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: ioredis and memcached moved from dependencies to optional peerDependencies. Installing this package no longer installs either client, so a project using only Redis stops dragging in a Memcached client and vice versa — and the Memory backend needs neither. Add the client you use to your own dependencies: npm install ioredis or npm install memcached. Each is now required when its wrapper is constructed rather than at import time, so a missing client raises a message naming the package to install instead of breaking require('cache-envelop') for everyone.
  • Breaking: removed redis.client.close(). The wrapper used to patch a close alias 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. Call redis.close() instead — it is unchanged, and .client is now exactly the ioredis instance with nothing bolted on.

Additive in the same release:

  • New Memory backend: 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, and maxKeys bounds 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 ordinary Redis wrapper, since it speaks the Redis protocol.
  • npm run test:smoke packs the publishable tarball, installs it with no peer dependencies, and checks that require, Memory and both missing-client messages behave. It runs in CI.

3.1.0 (2026-08-31) — minor, additive only:

  • Redis implements the portable coreget, set, del — alongside close, with the same contracts, validation and error messages as Memcached. .client is untouched, so nothing that used the raw ioredis instance changes.
  • Redis#set sends a fractional ttl as PX rather than truncating it, and ttl: 0 as a plain SET (EX 0 is an error in Redis). Values are JSON-encoded on write and decoded on read.
  • New Redis option strictKeys: true applies 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 declared implements CacheCore.
  • Validation moved to src/validators.js so both backends enforce one contract from one place.
  • New test/coreContract.test.js runs 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: options is validated. Unknown keys and negative / fractional / non-numeric index/start/end now throw. Previously a typo like listDel(key, ttl, { idx: 0 }) fell through to the "no options" branch and cleared the whole list, a negative index silently became an unshift in listSet, and listGet accepted a negative index that listSet rejected.
  • Memcached#listSet / listDel / hashSet: ttl is validated up front and is documented as required, matching set and hashDel. It was already effectively required (the internal set threw), but JSDoc marked it optional and the error surfaced only after a needless cache read.
  • Added TypeScript declarations (index.d.ts), an engines field (node >= 20), and a npm run typecheck step in CI.

2.0.0 (2026-07-31) — major, contains breaking behavior changes described below:

  • Memcached: numeric keys (documented as supported) no longer throw a TypeError.
  • Memcached#set: ttl: 0 is now accepted (means "no expiration"); previously it was rejected the same as a missing ttl.
  • Memcached#listSet / listGet / listDel: index/start/end of 0 are now handled correctly instead of being treated as "not provided".
  • Memcached#hashDel(key, field) (without options) no longer throws a raw TypeError; it now throws a descriptive validation error if options.ttl is missing.
  • Memcached#hashGet(key, field) on a missing key now returns undefined instead of throwing.
  • Redis/Memcached: passing an empty/blank connection string now throws instead of silently connecting to 127.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.