Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev).

- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters.
- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters.
- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters.
- [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api).
- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests.

Expand All @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev).
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads.
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/).
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

**Observability**
Expand Down
3 changes: 2 additions & 1 deletion 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, 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 @@ -51,7 +52,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki
| [@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) | Cloudflare Durable Object and Rate Limit adapters. | [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) |
| [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) |

## Observability
Expand Down
290 changes: 290 additions & 0 deletions apps/content/docs/helpers/cache.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
---
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

The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. You can create your own custom store or use one of the provided adapters. A router shares a single store, provided through the request context as defined by the `CacheContext` interface.

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

await store.set('planet:1', { id: 1, name: 'Earth' }, {
tags: ['planets', 'planet:1'],
ttl: 60_000,
})

const entry = await store.get('planet:1')

await store.revalidateTag('planets') // now `get` misses
```

An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` window afterward, during which `get` still returns it with a past `expiresAt` so callers can serve it stale while refreshing. Revalidating a tag invalidates every entry associated with it, fresh or stale.

## Adapters

| Name | Adapter for |
| ------------------- | ------------------------------------------------------------------------------------------ |
| `MemoryCacheStore` | In-memory storage |
| `RedisCacheStore` | [Redis](https://github.com/redis/redis) |
| `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) |
| `experimental_KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) |
| `experimental_WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only |

Keys may be any serializable value. Strings are used verbatim, while anything else is encoded with `encodeCacheKey`: serialized first, so complex values like Date, Map, or Set become plain JSON, then canonicalized, so structurally equal keys resolve the same entry regardless of property order. Reuse it when implementing your own store.

<CodeGroup>

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

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

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

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

// RedisCacheStore lazily connects to Redis when needed.
// You can still call `client.connect()` manually, but it is optional.
await client.connect()

const store = new RedisCacheStore({
/**
* The Redis client to store entries in. Connected lazily when needed.
*/
redis: client,

/**
* The prefix to use for Redis keys.
*
* @default undefined
*/
prefix: undefined,

/**
* Serializer for cached outputs. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})
```

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

const store = new VercelCacheStore({
/**
* The Vercel Runtime Cache to use. Outside Vercel,
* it falls back to an in-memory cache.
*
* @default getCache()
*/
cache: getCache(),

/**
* Serializer for cached outputs. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})
```

```ts cloudflare-kv
import { experimental_KVCacheStore as KVCacheStore } from '@orpc/cloudflare'

export default {
async fetch(request, env) {
// KV is eventually consistent: writes and revalidations may take
// 60 seconds or more to be visible in other locations.
const store = new KVCacheStore({
/**
* The KV namespace to store entries in.
*/
kv: env.CACHE_KV,

/**
* The prefix to use for KV keys.
*
* @default undefined
*/
prefix: undefined,

/**
* Serializer for cached outputs. Outputs containing Blob or File
* values are ignored and never stored.
*
* @default RPCSerializer
*/
serializer: undefined,
})
},
}
```

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

export default {
async fetch(request, env, ctx) {
// Workers Caching caches whole responses in front of the Worker via the
// `cache-control` and `cache-tag` plugin headers; this store only purges
// tags on revalidation. Requires `"cache": { "enabled": true }` in your
// wrangler configuration. Purges are scoped to the calling entrypoint,
// tags match case-insensitively, and purge calls always use the Free
// tier rate limits regardless of your plan.
const store = new WorkersCacheStore({ cache: ctx.cache })
},
}
```

</CodeGroup>

## Cache Middleware

The `cache` helper creates middleware that caches the output of [procedures](/docs/procedure). On a hit it returns the cached output without executing the handler, and on a miss it executes the handler and stores the result. The `key`, `tags`, `ttl`, `swr`, and `enabled` options accept static values or functions of the middleware options and input.

The `key` is optional: by default it is derived from the procedure path and input. When provided, strings are used verbatim, while any other serializable value is combined with the procedure path and encoded into a key.

```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_000, // Optional fresh lifetime, default is no expiry
swr: 300_000, // Optional stale-while-revalidate window, default is 0
}),
)
.handler(({ input }) => {
return { id: input.id, name: `Planet ${input.id}` }
})

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

:::info
Entries are stored only when the handler succeeds. Streaming outputs, such as [AsyncIteratorObject](/docs/async-iterator-object) and readable streams, are never cached.
:::

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

### Stale While Revalidate

When an entry is past `ttl` but within the `swr` window, the middleware returns the stale output immediately and re-executes the procedure in the background to refresh the entry. Concurrent stale hits may each trigger a refresh; the cache never serves anything older than `ttl + swr`.

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

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

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

## Revalidate Middleware

The `revalidate` helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. It accepts one tag, a non-empty list of tags, or a function of the middleware options and input. If the procedure throws, the revalidation is skipped.

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

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

## Handler Plugin

The `CacheHandlerPlugin` reflects the cache activity of [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware) into response headers. It does nothing by default; only the headers you list are set:

- `orpc-cache-tag` carries the tags the response depends on.
- `orpc-cache-tag-invalidation` carries the tags revalidated by the request, useful 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. They are only set on GET and HEAD responses and never override existing 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; the encoded form stays unambiguous under case folding. Use `decodeCacheTagHeader` to parse a header back into tags.

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

const handler = new RPCHandler(router, {
plugins: [
new CacheHandlerPlugin({
headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER],
}),
],
})
```

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

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

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

```ts
new CORSPlugin({
exposeHeaders: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER],
})
```

:::

:::info
The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one.
:::
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
3 changes: 2 additions & 1 deletion packages/ai-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev).

- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters.
- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters.
- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters.
- [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api).
- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests.

Expand All @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev).
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads.
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/).
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

**Observability**
Expand Down
3 changes: 2 additions & 1 deletion packages/arktype/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev).

- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters.
- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters.
- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters.
- [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api).
- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests.

Expand All @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev).
- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/).
- [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads.
- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/).
- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/).
- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC.

**Observability**
Expand Down
Loading