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

@celsian/cache

v0.6.3

Published

Caching utilities for CelsianJS

Readme

@celsian/cache

KV store, response caching, and session management for CelsianJS.

Install

npm install @celsian/cache

Usage

createResponseCache() is not a plugin: it wraps a fetch handler (or a single route handler), because it has to intercept the whole Response. Use separate stores for sessions and the response cache.

import { createApp } from 'celsian';
import { MemoryKVStore, createResponseCache, createSessionManager } from '@celsian/cache';

const cacheStore = new MemoryKVStore({ maxEntries: 10_000 });
const sessionStore = new MemoryKVStore({ maxEntries: 50_000 });

const cache = createResponseCache({ store: cacheStore, ttlMs: 60_000 });
const sessions = createSessionManager({ store: sessionStore });

const app = createApp();

// Per route: wrap the work you want cached.
app.get('/products', async (req, reply) =>
  cache.cached(req, async () => reply.json({ products: await listProducts() })),
);

// Or wrap the whole app handler at the adapter boundary INSTEAD.
// const handler = cache.wrap(app.handle.bind(app));

// Purge after a write. A key without a query string purges every query variant.
app.post('/products', async (req, reply) => {
  await createProduct(req.parsedBody);
  await cache.invalidate('GET:/products');
  return reply.json({ ok: true });
});

Cache at ONE level. Wrapping the app handler with wrap() and calling cached() inside a route means the inner call waits on the outer call's own in-flight execution for the same key, and the request hangs until your server times it out. Pick the route form or the adapter form.

Do not share one store between sessions and the response cache. They compete for the same LRU budget, so ordinary cache churn evicts live sessions and logs users out. Give each its own store with its own maxEntries.

Response cache

What is in the key

The readable part of the key is ${method}:${scheme}//${host}:${pathname}${normalizedQuery}, e.g. GET:https//app.example:/data, followed by a SHA-256 digest of that plus every partitioned header.

  • Scheme and host are part of the key. One process serving several domains shares a store, so keying on the path alone served one tenant's body to another, and omitting the scheme let http://x.app/data and https://x.app/data share one entry.
  • The Host header is partitioned separately from the URL authority. On a server bound to 0.0.0.0 the URL authority is the BIND address, identical for every tenant, so keying on it alone collapsed all domains into one bucket again. Both are in the key, so tenants stay separate however the adapter builds request.url.
  • Query parameters are sorted, so ?a=1&b=2 and ?b=2&a=1 share one entry.
  • Origin is partitioned eagerly, so a Vary: Origin response cannot replay one origin's body or CORS headers to another.
  • Accept-Encoding is partitioned eagerly, so a compressed response is storable and a gzip body is never served to a client that did not ask for one.
  • Host-rewriting headers are partitioned eagerly too: Forwarded, X-Forwarded-Host, X-Forwarded-Proto, X-Forwarded-Scheme, X-Forwarded-Port, X-Forwarded-Server, X-Forwarded-Prefix, X-Forwarded-Uri, X-Forwarded-Ssl, X-Host, X-Http-Host-Override, X-Original-Host, X-Original-URL, X-Original-Uri, X-Rewrite-URL.

Keys are hashed, so their stored length is bounded by construction. maxKeyLength (default 512) only decides how much of the readable part is kept in front of the digest. No request can make its own key too long to be cached, which used to switch off the cache, and the stampede protection with it.

Cache poisoning and varyHeaders

A denylist of host-rewrite headers cannot be complete, and this one is not. Reverse proxies, CDNs, and frameworks invent new host/scheme override headers continually (X-Forwarded-*, X-Original-*, vendor-specific spellings), and the cache cannot know which header your handler reads. The list above closes the vectors we know about, it is a mitigation, not a boundary.

Any request header your handler reflects into a response MUST be listed in varyHeaders. That is the only complete defence, because only you know what your handler reads. Otherwise the header is an unkeyed input: one unauthenticated request can plant an attacker-chosen value (say a <script src> built from X-Forwarded-Host) in a response that is then served to every anonymous visitor for the whole TTL. This is the canonical web-cache-poisoning attack.

If your handler builds absolute URLs at all, prefer deriving them from configuration rather than from request headers. A handler that never reflects a request header cannot be poisoned through one.

const cache = createResponseCache({
  store,
  varyHeaders: ['accept-language', 'x-country'],
});

The cache will not store a response unless every field in its Vary is represented in the key. Configured fields are merged with the handler's Vary.

Cache-busting floods

Query strings enter the key verbatim, so an unauthenticated ?cachebust=N flood mints unlimited keys and LRU-evicts the entries you wanted cached. Restrict the key to the parameters your handler actually reads:

const cache = createResponseCache({ store, queryParams: ['page', 'sort'] });

Credentials and privacy

This is a SHARED cache: one stored response is replayed to everyone whose request produces the same key. The rule is therefore fail closed, following RFC 9111 section 3.5:

A request that carries anything that might be a credential is only cached, and only served from cache, when the response says Cache-Control: public or s-maxage=N (N > 0).

"Might be a credential" is decided by an ALLOW-list, not a denylist. A request header that is not a well-known public header (Accept*, User-Agent, Referer, Sec-*, If-*, Range, Origin, X-Forwarded-For, ...), not one of the eagerly-partitioned host headers, and not in your varyHeaders or publicHeaders, counts as a possible credential. A denylist of authorization/cookie/proxy-authorization failed open for X-API-Key, X-Auth-Token, X-Session-Id and every other auth transport, and a per-user JSON response typically carries no Set-Cookie, no Vary and no Cache-Control to catch it downstream, so one user's token was stored and handed to the next.

const cache = createResponseCache({
  store,
  // Definitely credentials, even if they look public.
  credentialHeaders: ['x-api-key'],
  // Definitely NOT credentials: infrastructure noise that should stay cacheable.
  publicHeaders: ['cf-ray', 'x-request-id'],
});

If a route is genuinely public, say so on the response and it is cached for everyone, credentialed or not:

reply.header('cache-control', 'public, max-age=300').json(publicData);

Responses marked private, no-store, no-cache, max-age=0, s-maxage=0, Pragma: no-cache, Vary: *, an expired or unparseable Expires, or carrying Set-Cookie, are never stored.

Response freshness

The response's own lifetime wins whenever it is shorter than the configured ttlMs: s-maxage first, then max-age, then Expires. A max-age=1 response is stored for one second, not for the configured default.

Compression

compress() and createResponseCache() compose. Compressed responses carry Vary: Accept-Encoding, which is represented in the key, and bodies are stored as BYTES, so a gzip (or Brotli, image, font, PDF, protobuf) body round-trips exactly. There is no need for varyHeaders: ['accept-encoding'], it is already partitioned.

Stampede protection

Concurrent requests for the same cold key execute the origin once; the rest wait and are served the stored entry. If the result turns out not to be storable (e.g. Cache-Control: private), the waiters execute the handler themselves rather than being served a response that was never eligible for sharing.

A client cannot opt out of this. Cache-Control: no-cache, max-age=0, no-store and Pragma: no-cache still bypass the STORED entry (the request is never served something older than it asked for), but they do not skip the single flight and do not suppress the write. Honouring them there let one header turn N concurrent requests into N origin executions, which is precisely the amplification a shared cache exists to prevent. For request no-store this is a deliberate deviation from RFC 9111 section 5.2.1.5: nothing private can leak through it, because only a response that already passed the rules above is ever written.

Sessions

A session is persisted only once it holds data. Creating a session (or fromRequest on a cookie-less request) writes nothing, so a crawler cannot fill the store with empty 24h entries and evict every logged-in user. Call save() after putting something in the session; save() on an emptied session removes the entry.

Rotate the id at every privilege boundary

fromRequest() never adopts an id the server did not issue, but an attacker can obtain a real id and plant it in a victim's browser. Unless the id changes when the victim logs in, the attacker's cookie is a valid handle on the logged-in session. That is session fixation, and regenerate() is the fix:

app.post('/login', async (req, reply) => {
  const user = await authenticate(req.parsedBody);
  const session = await sessions.fromRequest(req);

  // Rotate FIRST: the id in the incoming cookie must never survive a login.
  await session.regenerate();

  session.set('user', { id: user.id });
  await session.save();
  return reply.header('set-cookie', sessions.cookie(session.id)).json({ ok: true });
});

regenerate() rotates in place: session.id becomes the new id, the old store entry is deleted, and a later save() writes the new id. It also returns the session, so const s = await session.regenerate() reads naturally. Call it on any privilege change (login, logout, role change, step-up auth).

Session ids come from crypto.getRandomValues (192 bits). A custom generateId is percent-encoded into the cookie when it contains characters outside the RFC 6265 cookie-octet set, and decoded on read, so it round-trips.

KV store

MemoryKVStore is single-process. maxEntries (default 10 000) bounds it with LRU eviction; reads and writes both count as use. incr/decr perform the whole read-modify-write synchronously, so concurrent increments cannot lose updates, a distributed implementation must use a native atomic primitive (Redis INCRBY), never a GET followed by a SET.

keys(pattern) accepts globs (* within one :-delimited segment, ** across segments, ? for one character) and matches without backtracking, so a user-supplied pattern cannot burn the event loop.

Documentation

See the main repository for full docs, examples, and API reference.

License

MIT