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

@sdxc/workers-cache

v2026.9.29

Published

Cache tags, purging and cache-status reads for Cloudflare Workers Cache

Readme

@sdxc/workers-cache

Cache tags, purging and cache-status reads for Cloudflare Workers Cache.

Freshness is standard HTTP: Cache-Control and ETag are specified, so any runtime can write them. Invalidation is not: tagging a response uses a Cache-Tag header no specification defines, and clearing entries means calling purge on the platform's own cache object.

This package holds that vendor half — a typed tag vocabulary, the serializer, purging, a cache-status readers, a recording double for tests, and a remix/router middleware that applies all of it.

Installation

npm add @sdxc/workers-cache

Every purge reports its outcome as a Result from @sdxc/result, which is where isFailure comes from, and the middleware entry point is built for the router in remix. Both install alongside this package, as does @sdxc/logger, which the middleware enriches when an invocation log is current. The standard Cache-Status field is parsed with @sdxc/structured-fields, installed alongside too.

Usage

Declare A Tag Vocabulary

Write the tags a content model needs once, as functions rather than strings, so the response header and the purge that clears it cannot drift apart:

import { createTags } from "@sdxc/workers-cache";

export let TAGS = createTags({
	post: (id: string) => `post:${id}`,
	postsByAuthor: (author: string) => `posts:${author}`,
	postList: () => "posts",
});

TAGS.post("123"); // "post:123", branded as a CacheTag

Every builder validates what it produced, so a tag the platform would drop throws where it was written instead of disappearing at the edge.

Tag A Response

import { cacheTag } from "@sdxc/workers-cache";

let response = new Response(body, {
	headers: {
		"Cache-Control": "public, max-age=86400",
		"Cache-Tag": cacheTag([TAGS.post(post.id), TAGS.postList()]),
	},
});

cacheTag keeps the order you wrote and collapses repeats, so two participants naming the same tag produce one header entry.

Invalidate By Tag

import { isFailure } from "@sdxc/result";
import { purge } from "@sdxc/workers-cache";

let result = await purge(cache, { tags: [TAGS.post(post.id), TAGS.postList()] });
if (isFailure(result)) throw result.error;

cache is whatever platform cache object the caller holds — a binding, or anything else that answers purge(selector). It is a parameter rather than a global, which is what keeps this package free of a runtime import and testable with a double.

Declare Caching From A Request Handler

import cache from "@sdxc/workers-cache/middleware";
import { createRouter } from "remix/router";

let router = createRouter({ middleware: [cache({ cache: (ctx) => ctx.cacheBinding })] });

router.get("/posts/:id", async (ctx) => {
	let post = await findPost(ctx.params.id);
	ctx.cache("public, max-age=86400", TAGS.post(post.id), TAGS.postList());
	return html(render(post));
});

router.post("/posts/:id", async (ctx) => {
	await updatePost(ctx.params.id, await ctx.request.formData());
	await ctx.cache.purge(TAGS.post(ctx.params.id), TAGS.postList());
	return redirect(`/posts/${ctx.params.id}`);
});

ctx.cache() records intent and writes nothing itself. The headers land on the finished response after the handler returns, which is what lets the middleware inspect that response before agreeing to cache it.

API

createTags(vocabulary)

Wraps each builder in a validating one and narrows its return type to a branded CacheTag, so only a vocabulary can produce a value where a tag is expected. A builder throws CacheTagError when the tag it produced is empty, longer than MAX_TAG_LENGTH, outside printable ASCII, or contains a space, a comma or a ".

let TAGS = createTags({ post: (id: string) => `post:${id}` });
TAGS.post("1"); // "post:1"
TAGS.post("a b"); // throws CacheTagError

cacheTag(tags: readonly CacheTag[]): string

Serializes a tag list into a Cache-Tag header value, preserving caller order and dropping repeats. It throws CacheTagError when the list is empty, holds a tag the platform would reject, or serializes beyond MAX_CACHE_TAG_HEADER_LENGTH. An empty list is rejected rather than written as an empty header, because an empty Cache-Tag reads as tagged while purging nothing.

cacheTag([TAGS.post("1"), TAGS.postList()]); // "post:1,posts"

purge(cache: CacheInterface, options: PurgeOptions): Promise<Result<void, PurgeError>>

Invalidates entries by tag, by URL prefix, or entirely. Success means the platform accepted the purge; purging is eventually consistent, so the next read is not guaranteed to miss. An empty tag list, a blank prefix, an invalid tag, and options selecting nothing all fail without calling the platform.

await purge(cache, { tags: [TAGS.postList()] });
await purge(cache, { prefixes: ["example.com/blog/"] });
await purge(cache, { everything: true }); // incidents, not content writes

A platform that declines a purge resolves rather than rejecting, so the outcome it reports decides the result: a declined purge comes back as a PurgeError carrying the selector that stayed stale and the platform's issues as cause.

cacheStatus(response: Response): CacheStatus

Reads how the platform treated a response, from the cf-cache-status header, as one of "hit" | "miss" | "expired" | "bypass" | "unknown". An absent or unrecognized value reads as "unknown" rather than being reported as a miss.

| Header value | Status | | --------------------------------------------- | --------- | | HIT | hit | | MISS | miss | | EXPIRED, STALE, REVALIDATED, UPDATING | expired | | BYPASS, DYNAMIC | bypass | | anything else, or no header | unknown |

cacheHops(response: Response): CacheHop[]

Reads the standard RFC 9211 Cache-Status field, one entry per cache the response passed through, in the field's order: the cache closest to the origin first, the one nearest the client last. Each entry carries the parameters that cache sent, with fwd-status read as fwdStatus, and extension parameters dropped. An absent field, or one that is not a valid Structured Field List or breaks RFC 9211's parameter types, reads as [], since the RFCs have a recipient ignore an invalid field.

// Cache-Status: OriginCache; hit; ttl=1100, "CDN Company Here"; fwd=uri-miss; stored
cacheHops(response);
// [
//   { cache: "OriginCache", hit: true, ttl: 1100 },
//   { cache: "CDN Company Here", fwd: "uri-miss", stored: true },
// ]

createRecordingCache(options?: RecordingCacheOptions): RecordingCache

A CacheInterface that records purges instead of calling a platform. The returned object exposes purges (every selector in call order), purgedTags (tags flattened across tag purges), failWith(error) and declineWith(issues) to arm a rejection or a refusal, and reset(). options.failWith and options.declineWith arm the same behavior up front.

let cache = createRecordingCache();
await purge(cache, { tags: [TAGS.postList()] });
cache.purgedTags; // ["posts"]

Constants

CACHE_TAG_HEADER, CACHE_CONTROL_HEADER and CACHE_STATUS_HEADER are the header names this package reads and writes. MAX_TAG_LENGTH (1024) and MAX_CACHE_TAG_HEADER_LENGTH (16384) are the size limits a tag and a serialized header are held to. NON_CACHEABLE_POLICY is the private, no-store value a refused declaration is downgraded to. CACHEABLE_METHODS holds GET and HEAD; CACHEABLE_STATUS_CODES holds 200, 203, 204, 206, 300, 301, 302, 304, 307, 308, 404, 405, 410, 414 and 501. They are exported so tests and logs assert against the same values the middleware uses.

Errors

CacheTagError

A tag the platform would reject, or a tag list that cannot be serialized. The rejected tag stays on the error as tag, quoted in the message so whitespace remains visible.

PurgeError

A purge that did not take effect. selector carries what the call meant to invalidate, so a log line names the tags or prefixes still serving stale content, and cause carries the platform's own rejection.

Types

type CacheTag = string & { readonly [CACHE_TAG_BRAND]: true };

type CachePolicy = string;

type CacheStatus = "hit" | "miss" | "expired" | "bypass" | "unknown";

type CacheForwardReason =
	"bypass" | "method" | "uri-miss" | "vary-miss" | "miss" | "request" | "stale" | "partial";

interface CacheHop {
	cache: string;
	hit?: boolean;
	fwd?: CacheForwardReason;
	fwdStatus?: number;
	ttl?: number;
	stored?: boolean;
	collapsed?: boolean;
	key?: string;
	detail?: string;
}

type PurgeOptions =
	{ tags: readonly CacheTag[] } | { prefixes: readonly string[] } | { everything: true };

interface PurgeSelector {
	tags?: string[];
	pathPrefixes?: string[];
	purgeEverything?: boolean;
}

interface PurgeIssue {
	code: number;
	message: string;
}

interface PurgeOutcome {
	success: boolean;
	errors: readonly PurgeIssue[];
}

interface CacheInterface {
	purge(selector: PurgeSelector): Promise<PurgeOutcome>;
}

PurgeOptions is what a caller writes; PurgeSelector is the normalized form handed to the platform, with tags validated and deduplicated and exactly one field set. Its field names mirror the platform's own purge options, so a Workers Cache binding satisfies CacheInterface directly. CacheTag carries a type-only brand, so only a vocabulary built by createTags() produces one and a renamed tag is a compile error at the purge call. CacheTags<Vocabulary> and TagVocabulary describe what createTags takes and returns, and PurgeByTags, PurgeByPrefixes and PurgeEverything are the three members of PurgeOptions under their own names.

cache(options: WorkersCacheMiddlewareOptions): Middleware

Default export of @sdxc/workers-cache/middleware. It publishes a callable context.cache and writes the declared headers onto the finished response.

options.cache is the whole option set: a CacheInterface, or a (context) => CacheInterface resolver read off the request. The factory takes no policy, which is what makes registering it once on a router safe — a route that never calls context.cache() is left untouched, so no route inherits a lifetime it did not choose, and two handlers can pick different ones. The interface is resolved and closed over, so context.cache.purge() takes tags and only tags.

context.cache(policy, ...tags) / context.cache({ policy, tags })

Records intent. Tags accumulate across every call in the request into one Cache-Tag header, so a router-scoped middleware and its handler both contribute instead of overwriting each other. The policy is replaced by the most recent declaration, since only one lifetime can be written.

context.cache.purge(...tags): Promise<Result<void, PurgeError>>

Awaits the platform call and returns the outcome, because a write action usually redirects to the page it just invalidated and a deferred purge would race the follow-up request.

context.cache.purgeLater(...tags): void

Queues a purge that runs once the response has been produced, for invalidations nobody is about to observe. Failures are logged, never thrown.

Refusals

After next() resolves, the middleware inspects the finished response — including headers added between the declaration and the response — and only then writes anything:

| Condition | Behavior | | ------------------------------------------------------------------------- | --------------------------------------------------------- | | Response carries Set-Cookie | Downgrade to private, no-store, write no tags, and warn | | Request carried a session and the policy is public | Downgrade to private, no-store, write no tags, and warn | | Method is GET or HEAD and the status is cacheable, with a declaration | Write Cache-Control and Cache-Tag | | Anything else | Leave the response exactly as the handler built it |

A downgrade warns on the current log and throws UnsafeCachePolicyError in development, since it means a route asked for something unsafe. That error and its CacheRefusalReason ("set-cookie" | "session-with-public-policy") are exported from @sdxc/workers-cache/middleware, alongside the CacheDeclaration, CacheDeclarationOptions and WorkersCacheMiddlewareOptions types.

The public directive is matched as its own token, so max-age=60 is never mistaken for a public policy. A session published by an upstream session middleware is the precise signal for whether a request is identified; without one, any cookie makes a public policy a refusal. Development is read from NODE_ENV when it says which mode this is, and otherwise from a request to localhost, 127.0.0.1, [::1] or 0.0.0.0.

Pattern: Named Policies In One Module

The middleware carries no policy, so shared lifetimes belong in a module of named constants. Those are plain strings, so jobs and hand-built responses reuse the same ones a handler declares. Any builder produces them; this one comes from @sdxc/http:

import { policy } from "@sdxc/http/cache";

export let PUBLIC_PAGE = policy({
	visibility: "public",
	maxAge: "1 day",
	staleWhileRevalidate: "1 week",
}).toString();

export let SHORT_LIVED = policy({ visibility: "public", maxAge: "5 minutes" }).toString();
// Two handlers, two lifetimes, one middleware registration.
ctx.cache(PUBLIC_PAGE, TAGS.post(post.id), TAGS.postList());
ctx.cache(SHORT_LIVED, TAGS.postList());

Pattern: A Group Tag From A Middleware

A middleware scoped to a group of routes can contribute a tag that applies to every response in that group, so one purge invalidates all of it. Tags accumulate, so the handler's own tags survive:

import type { Middleware } from "remix/router";

let tenantTag: Middleware = (ctx, next) => {
	ctx.cache(PUBLIC_PAGE, TAGS.tenant(ctx.tenant.id));
	return next();
};

Accumulation happens along a middleware chain and within a handler, since the router has no nested routes to accumulate up.

Pattern: Testing A Cached Route

The recording cache stands in for the platform, so a test asserts on the headers a route emits and the selectors it purged with no binding and no network:

import { isFailure } from "@sdxc/result";
import { cacheStatus, createRecordingCache, purge } from "@sdxc/workers-cache";

let cache = createRecordingCache();
let response = await router.fetch(new Request("https://example.com/posts/1"));

expect(response.headers.get("Cache-Control")).toBe(PUBLIC_PAGE);
expect(response.headers.get("Cache-Tag")).toBe("post:1,posts");
expect(cacheStatus(response)).toBe("unknown"); // no edge in a test

The double also covers the two failure paths a content write depends on — a platform that rejects, and one that declines the purge while resolving:

let cache = createRecordingCache({ failWith: new Error("edge unavailable") });
expect(isFailure(await purge(cache, { tags: [TAGS.postList()] }))).toBe(true);

cache.reset();
cache.declineWith([{ code: 1122, message: "rate limited" }]);
expect(isFailure(await purge(cache, { tags: [TAGS.postList()] }))).toBe(true);

Versioning

Releases are dated rather than semantic. A version is the UTC date it was published, written YYYY.M.D, so 2026.9.4 is the release from 4 September 2026. At most one release goes out per day.

Those numbers say when, not what: a later date means a later release and carries no compatibility promise. Any release may change or remove an export.

Depend on one exact date, and move it when you are ready to take the change:

{
	"dependencies": {
		"@sdxc/workers-cache": "2026.9.4"
	}
}

A caret or tilde range reads the date as major, minor and patch, so it accepts every later release in the same year. An exact version keeps the upgrade yours to schedule.

License

MIT

Author

Sergio Xalambrí