Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
282fd1a
feat(cache): add tag-based caching and revalidation helpers
dinwwwh Aug 27, 2026
1faf825
Merge remote-tracking branch 'middleapi/main' into claude/orpc-cache-…
dinwwwh Aug 27, 2026
faa7836
refactor(cloudflare): drop the Cache API cache store
dinwwwh Aug 28, 2026
9b503ad
refactor(cache): default store key prefixes to none
dinwwwh Aug 28, 2026
93288dd
refactor(cloudflare): rename cache stores to KVCacheStore and Workers…
dinwwwh Aug 28, 2026
f35e3ad
refactor(cache): unify store constructors on a single options object
dinwwwh Aug 28, 2026
6c3f4dd
chore(cloudflare): shorten package description
dinwwwh Aug 28, 2026
8d5c356
chore: shorten the cloudflare package description in package lists
dinwwwh Aug 28, 2026
71cf231
refactor(cache): serialize outputs with RPCSerializer and ignore blob…
dinwwwh Aug 28, 2026
9329af7
refactor(cache): encode keys inside stores with sorted key material
dinwwwh Aug 28, 2026
4649b4c
refactor(shared): recurse into Maps and Sets in deepSortKeys
dinwwwh Aug 28, 2026
b2b1ba5
refactor(cache): canonicalize keys after serialization via shared enc…
dinwwwh Aug 28, 2026
87232ff
refactor(cache): construct the default key serializer per call
dinwwwh Aug 28, 2026
99ff5a3
feat(cache): allow configuring the memory store key serializer
dinwwwh Aug 28, 2026
b70ec44
docs(cache): document key encoding in the adapters section
dinwwwh Aug 28, 2026
1cca86b
refactor(cache): make the handler plugin inert until headers are conf…
dinwwwh Aug 28, 2026
f95ba53
fix(cache): percent-encode uppercase tag characters for case-insensit…
dinwwwh Aug 28, 2026
8129954
test(cache): reach full adapter coverage and hoist key encoding
dinwwwh Aug 28, 2026
17c31c2
refactor(cache)!: publish as @orpc/experimental-cache with prefixed c…
dinwwwh Aug 28, 2026
c2356fb
refactor(cache)!: rework the store contract and header reflection
dinwwwh Sep 3, 2026
313a01f
Merge remote-tracking branch 'upstream/main' into claude/orpc-cache-i…
dinwwwh Sep 3, 2026
e9c8063
refactor(shared): split encodeCacheTag out of encodeCacheTagHeader
dinwwwh Sep 4, 2026
8a12845
feat!: add Upstash and Bun cache stores, take store clients positionally
dinwwwh Sep 4, 2026
49afa3d
test(cache): add cross-adapter compatibility suites for the Redis stores
dinwwwh Sep 4, 2026
1d9265f
docs: sync the packages tables across every package README
dinwwwh Sep 4, 2026
7958a2a
fix(cache): wait for the stale-while-revalidate refresh without cache…
dinwwwh Sep 4, 2026
479d744
fix(cache): hand the stale-while-revalidate refresh to cache/waitUnti…
dinwwwh Sep 4, 2026
f81e29c
docs(cache): describe the headers option's current override behavior
dinwwwh Sep 4, 2026
3aaafb6
Update handler-plugin.ts
dinwwwh Sep 4, 2026
12694be
improve
dinwwwh Sep 4, 2026
1c71159
feat(cloudflare)!: remove experimental_KVCacheStore
dinwwwh Sep 4, 2026
082d46f
feat(cache): add per-key locks so concurrent misses fill an entry once
dinwwwh Sep 5, 2026
685f61f
refactor(shared): move MemoryLock out of the cache package
dinwwwh Sep 5, 2026
0844fe7
Merge remote-tracking branch 'upstream/main' into claude/orpc-cache-i…
dinwwwh Sep 8, 2026
e9f5f59
feat(cache)!: fold get, set, and lock into one fetch method
dinwwwh Sep 8, 2026
cb97a43
feat(cache): let the Vercel store take any Lock, defaulting to Memory…
dinwwwh Sep 8, 2026
10d3af0
Revert "feat(cache): let the Vercel store take any Lock, defaulting t…
dinwwwh Sep 8, 2026
26f97f9
refactor(cache): share the Redis flow through BaseRedisCacheStore
dinwwwh Sep 8, 2026
7fed718
Merge remote-tracking branch 'upstream/main' into claude/orpc-cache-i…
dinwwwh Sep 9, 2026
ebf06cc
refactor(cache): move BaseRedisCacheStore to adapters/base-redis
dinwwwh Sep 9, 2026
2a368bb
refactor(cache): simplify the stores and unify their serializers
dinwwwh Sep 9, 2026
5b6652c
fix(cache): capture tag versions when a fill starts and report evicti…
dinwwwh Sep 9, 2026
69f8cc8
fix(cache): retry a NOSCRIPT script exactly once
dinwwwh Sep 9, 2026
468cd88
refactor(cache): replace the key-value snapshot hook with a fill hook
dinwwwh Sep 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions README.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions apps/content/docs/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki

| Package | Purpose | Related Guides |
| ------- | ------- | -------------- |
| [@orpc/experimental-cache](https://npmx.dev/package-docs/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](/docs/helpers/cache) |
| [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) |
| [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) |
| [@orpc/hibernation](https://npmx.dev/package-docs/@orpc/hibernation) | Leverage Hibernation APIs like Cloudflare's WebSocket Hibernation. | [Hibernation](/docs/integrations/hibernation) |
Expand All @@ -50,8 +51,8 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki
| [@orpc/experimental-msw](https://npmx.dev/package-docs/@orpc/experimental-msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) |
| [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) |
| [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) |
| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher and Rate Limit. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) |
| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Traces](/docs/integrations/cloudflare-traces) |
| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Cache, Publisher, and Rate Limit. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) |
| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Traces](/docs/integrations/cloudflare-traces) |
| [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) |

## Observability
Expand Down
335 changes: 335 additions & 0 deletions apps/content/docs/helpers/cache.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,335 @@
---
title: "Cache Helpers"
description: "Cache oRPC procedure output with tag-based revalidation, stale-while-revalidate, storage adapters, and a handler plugin that reflects cache tags in HTTP headers."
sidebar:
label: "Cache"
---

## Installation

```package-install
npm install @orpc/experimental-cache@beta
```

## Basic Usage

Everything builds on the `CacheStore` interface: `fetch` returns the entry under a key and fills it when there is none, and `revalidate` invalidates entries by tag. A router shares one store, passed through the request context as `cache/store`, as the `CacheContext` interface describes. Use one of these adapters or write your own:

| Name | Adapter for |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [`MemoryCacheStore`](#memory) | In-memory storage |
| [`RedisCacheStore`](#redis) | [Redis](https://github.com/redis/redis) |
| [`UpstashCacheStore`](#upstash) | [Upstash Redis](https://github.com/upstash/redis-js) |
| [`BunRedisCacheStore`](#bun) | [Bun's Redis](https://bun.com/docs/runtime/redis) |
| [`VercelCacheStore`](#vercel) | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) |
| [`experimental_WorkersCacheStore`](#cloudflare-workers-caching) | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only |

```ts twoslash
import { MemoryCacheStore } from '@orpc/experimental-cache/memory'
// ---cut---
const store = new MemoryCacheStore()

const entry = await store.fetch('planet:1', async () => ({ id: 1, name: 'Earth' }), {
tags: ['planets', 'planet:1'],
ttl: 60,
})

await store.revalidate({ tags: ['planets'] }) // the next `fetch` fills again
```

An entry is fresh for `ttl` seconds and kept for a further `swr` window, during which `fetch` still returns it with a past `expiresAt` while one caller refreshes it in the background. Revalidating a tag invalidates every entry carrying it, fresh or stale. Durations are in seconds throughout.

## Cache Middleware

The `cache` helper creates middleware that caches the output of [procedures](/docs/procedure). A hit returns the cached output without running the handler; a miss runs the handler once, even for concurrent callers, and stores the result. The `key`, `tags`, `ttl`, `swr`, and `enabled` options accept static values or functions of the middleware options and input.

`key` defaults to the procedure path and input. When provided, it is used as given, so procedures sharing a key share an entry.

```ts
import { cache, CacheContext } from '@orpc/experimental-cache'
import { MemoryCacheStore } from '@orpc/experimental-cache/memory'

const findPlanet = os
.$context<CacheContext>()
.input(z.object({ id: z.number() }))
.use(
cache({
key: (_, input) => `planet:${input.id}`,
tags: (_, input) => ['planets', `planet:${input.id}`],
ttl: 60, // Optional fresh lifetime in seconds, default is no expiry
swr: 300, // Optional stale-while-revalidate window in seconds, default is 0
}),
)
.handler(({ input }) => {
return { id: input.id, name: `Planet ${input.id}` }
})

const result = await call(
findPlanet,
{ id: 1 },
{ context: { 'cache/store': new MemoryCacheStore() } },
)
```

:::warning
Entries are stored only when the handler succeeds, and stores hand the output straight to their serializer. Values it cannot represent, such as [AsyncIteratorObject](/docs/async-iterator-object), readable streams, Blob, and File, do not survive the round trip, so do not cache procedures returning them.
:::

:::warning
An entry is shared by everyone using its key. If output depends on the requester, put the distinguishing part in `key`, or resolve `enabled` to `false` to bypass caching for that request.
:::

### Stale While Revalidate

Past `ttl` but within `swr`, the middleware returns the stale output at once and re-runs the procedure in the background to refresh the entry. Concurrent stale hits refresh once, and nothing older than `ttl + swr` is ever served.

On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, pass `cache/waitUntil` through the context so refreshes can finish:

```ts
export default {
async fetch(request, env, ctx) {
const { response } = await handler.handle(request, {
context: {
'cache/store': store,
'cache/waitUntil': ctx.waitUntil.bind(ctx),
},
})

return response ?? new Response('Not Found', { status: 404 })
},
}
```

The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are handled. Without it they surface as unhandled rejections, so on other runtimes pass one that reports them, for example `promise => promise.catch(console.error)`.

## Revalidate Middleware

The `revalidate` helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. The required `tags` option accepts a non-empty list or a function of the middleware options and input. When the procedure throws, or `tags` resolves to `null` or `undefined`, nothing is revalidated. When the store fails to revalidate, the request fails even though the mutation already ran, so the failure is visible; retrying such a request repeats the mutation.

```ts
import { revalidate } from '@orpc/experimental-cache'

const updatePlanet = os
.$context<CacheContext>()
.input(z.object({ id: z.number(), name: z.string() }))
.use(
revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }),
)
.handler(({ input }) => {
return input
})
```

## Handler Plugin

The `CacheHandlerPlugin` reflects the activity of [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware) into response headers. Only the headers you list are set:

- `orpc-cache-tag` carries the tags the response depends on.
- `orpc-cache-tag-invalidation` carries the tags the request revalidated, for invalidating tagged data in client caches.
- `cache-control` and `cache-tag` are the standard HTTP counterparts for response caches in front, such as CDNs or Cloudflare Workers Caching.

The plugin sets these over anything already on the response. To override them, set your own afterwards with [ResponseHeadersPlugin](/docs/plugins/response-headers).

Tags are joined with commas. Only `%`, `,`, uppercase letters, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Uppercase letters are encoded because caches like Cloudflare Workers Caching match tags case-insensitively, and the encoded form stays unambiguous under case folding. `decodeCacheTagHeader` from `@orpc/shared` parses a header back into tags.

```ts
import { CacheHandlerPlugin } from '@orpc/experimental-cache'

const handler = new RPCHandler(router, {
plugins: [
new CacheHandlerPlugin({
headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'],
}),
],
})
```

:::info[Response Caches in Front]
With `cache-control` and `cache-tag` listed, a response cache in front serves cached responses without invoking your server. Pair it with a purge-capable store, such as `experimental_WorkersCacheStore`, so revalidations purge the front cache too. Standard HTTP caches only store GET and HEAD responses, so this mainly benefits [OpenAPIHandler](/docs/openapi/handler) routes; RPC requests use POST.

`cache-control` uses `max-age`, not `s-maxage`, because [`s-maxage` carries `proxy-revalidate` semantics](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10) that forbid the stale reuse `swr` asks for. It therefore reaches browser caches too, which no tag purge can invalidate. Set your own `cache-control` when responses must stay out of them.
:::

:::info
When a procedure calls other procedures, only the first cache check and the first revalidation of the procedure the client called are reflected, so nested procedures never leak their tags into the response. Headers appear only on successful responses.
:::

:::tip[Cross-Origin Clients]
The headers carry oRPC-specific names on purpose: CDN conventions like `Cache-Tag` can be consumed and stripped by intermediaries before reaching the browser, while these always arrive intact. For cross-origin browser clients, list them in [CORSPlugin](/docs/plugins/cors)'s `exposeHeaders` so client code can read them:

```ts
new CORSPlugin({
exposeHeaders: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'],
})
```

:::

:::info
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
:::

## Adapters

### Memory

Stores entries in the process. Suited to development, testing, and single-instance deployments.

```ts
import { MemoryCacheStore } from '@orpc/experimental-cache/memory'

const store = new MemoryCacheStore({
/**
* Serializer used to encode non-string keys.
*
* @default RPCJsonSerializer
*/
serializer: undefined,
})
```

### Redis

Stores entries as Redis hashes and drives every operation through Lua scripts, so a hit costs one round trip and a miss two. The lock taken on a miss is released when the fill finishes, or after `lockTtl` if it never does. The client is connected lazily when needed.

```ts
import { RedisCacheStore } from '@orpc/experimental-cache/redis'
import { createClient } from 'redis'

const client = createClient({ url: 'redis://localhost:6379' })

const store = new RedisCacheStore(client, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,

/**
* Serializer for keys and cached outputs.
*
* @default RPCJsonSerializer
*/
serializer: undefined,

/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})
```

The Redis, Upstash, and Bun stores share `BaseRedisCacheStore` from `@orpc/experimental-cache/base-redis`, which holds the scripts and the flow. A store for another Redis-compatible client only has to run a script.

### Upstash

The [Redis](#redis) adapter for Upstash's REST client. It shares the key and entry format with `RedisCacheStore`, so both can serve the same database.

```ts
import { UpstashCacheStore } from '@orpc/experimental-cache/upstash'
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

const store = new UpstashCacheStore(redis, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,

/**
* Serializer for keys and cached outputs.
*
* @default RPCJsonSerializer
*/
serializer: undefined,

/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})
```

### Bun

The [Redis](#redis) adapter for Bun's built-in Redis client, from `@orpc/bun`. It shares the key and entry format with `RedisCacheStore`, so both can serve the same database.

```ts
import { BunRedisCacheStore } from '@orpc/bun'
import { redis } from 'bun'

const store = new BunRedisCacheStore(redis, {
/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,

/**
* Serializer for keys and cached outputs.
*
* @default RPCJsonSerializer
*/
serializer: undefined,

/**
* How long a lock may be held, in seconds, so a crashed holder frees its waiters.
*
* @default 10
*/
lockTtl: 10,
})
```

### Vercel

Stores entries in the [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache), expiring tags natively through `expireTag`. Outside Vercel, the default `getCache()` falls back to an in-memory cache.

```ts
import { VercelCacheStore } from '@orpc/experimental-cache/vercel'
import { getCache } from '@vercel/functions'

const store = new VercelCacheStore({
/**
* The Vercel Runtime Cache to use.
*
* @default getCache()
*/
cache: getCache(),

/**
* Serializer for keys and cached outputs.
*
* @default RPCJsonSerializer
*/
serializer: undefined,
})
```

### Cloudflare Workers Caching

A purge-only store from `@orpc/cloudflare`. [Workers Caching](https://developers.cloudflare.com/workers/cache/) caches whole responses in front of the Worker through the `cache-control` and `cache-tag` headers of the [Handler Plugin](#handler-plugin), so every `fetch` runs the procedure and stores nothing, and `revalidate` purges the tags from the front cache.

It requires `"cache": { "enabled": true }` in your wrangler configuration. Purges are scoped to the calling entrypoint, tags match case-insensitively, and purge calls use the Free tier rate limits regardless of your plan.

```ts
import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare'

const store = new WorkersCacheStore({
/**
* The Workers Caching purge surface, such as `ctx.cache`.
*
* @default cache from `cloudflare:workers`
*/
cache: undefined,
})
```
1 change: 1 addition & 0 deletions apps/content/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@orpc/client": "workspace:*",
"@orpc/contract": "workspace:*",
"@orpc/evlog": "workspace:*",
"@orpc/experimental-cache": "workspace:*",
"@orpc/openapi": "workspace:*",
"@orpc/opentelemetry": "workspace:*",
"@orpc/pino": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default antfu({
rules: {
'ts/consistent-type-definitions': 'off',
'ts/method-signature-style': ['off'],
'new-cap': ['error', { capIsNew: false, newIsCapExceptionPattern: '^experimental_', properties: true }],
'ban/ban': [
'error',
{
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@orpc/client": "workspace:*",
"@orpc/contract": "workspace:*",
"@orpc/evlog": "workspace:*",
"@orpc/experimental-cache": "workspace:*",
"@orpc/experimental-effect": "workspace:*",
"@orpc/experimental-msw": "workspace:*",
"@orpc/hibernation": "workspace:*",
Expand Down
Loading
Loading