sdxc

Type to search, or start from one of these:

@sdxc/workers-cache

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

npm add @sdxc/workers-cache
pnpm add @sdxc/workers-cache
yarn add @sdxc/workers-cache
bun add @sdxc/workers-cache
Depends on
remix
Used by
blog

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 reader, 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.

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 valueStatus
HIThit
MISSmiss
EXPIRED, STALE, REVALIDATED, UPDATINGexpired
BYPASS, DYNAMICbypass
anything else, or no headerunknown

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

ConditionBehavior
Response carries Set-CookieDowngrade to private, no-store, write no tags, and warn
Request carried a session and the policy is publicDowngrade to private, no-store, write no tags, and warn
Method is GET or HEAD and the status is cacheable, with a declarationWrite Cache-Control and Cache-Tag
Anything elseLeave 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);