From 282fd1ab932eba0deb7294441adb7d49890d4e85 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 27 Aug 2026 14:53:22 +0700 Subject: [PATCH 01/18] feat(cache): add tag-based caching and revalidation helpers --- README.md | 3 +- apps/content/docs/api-reference.mdx | 3 +- apps/content/docs/helpers/cache.mdx | 317 +++++++++++++ apps/content/docs/procedure.mdx | 2 +- apps/content/package.json | 1 + package.json | 1 + packages/ai-sdk/README.md | 3 +- packages/arktype/README.md | 3 +- packages/bun/README.md | 3 +- packages/cache/README.md | 165 +++++++ packages/cache/package.json | 79 ++++ packages/cache/src/adapters/memory.test.ts | 102 +++++ packages/cache/src/adapters/memory.ts | 73 +++ .../cache/src/adapters/output-serializer.ts | 42 ++ packages/cache/src/adapters/redis.test.ts | 149 ++++++ packages/cache/src/adapters/redis.ts | 156 +++++++ packages/cache/src/adapters/vercel.test.ts | 151 ++++++ packages/cache/src/adapters/vercel.ts | 100 ++++ packages/cache/src/handler-plugin.test.ts | 272 +++++++++++ packages/cache/src/handler-plugin.ts | 155 +++++++ packages/cache/src/index.test.ts | 11 + packages/cache/src/index.ts | 4 + packages/cache/src/middleware.test-d.ts | 121 +++++ packages/cache/src/middleware.test.ts | 433 ++++++++++++++++++ packages/cache/src/middleware.ts | 196 ++++++++ packages/cache/src/types.ts | 96 ++++ packages/cache/tests/e2e.test.ts | 77 ++++ packages/cache/tsconfig.json | 17 + packages/client/README.md | 3 +- packages/cloudflare/README.md | 3 +- packages/cloudflare/package.json | 1 + packages/cloudflare/src/cache-api.test.ts | 160 +++++++ packages/cloudflare/src/cache-api.ts | 170 +++++++ packages/cloudflare/src/cache.test.ts | 135 ++++++ packages/cloudflare/src/cache.ts | 146 ++++++ packages/cloudflare/src/index.ts | 3 + packages/cloudflare/src/workers-cache.test.ts | 47 ++ packages/cloudflare/src/workers-cache.ts | 59 +++ packages/cloudflare/wrangler.jsonc | 6 + packages/contract/README.md | 3 +- packages/effect/README.md | 3 +- packages/evlog/README.md | 3 +- packages/hibernation/README.md | 3 +- packages/json-schema/README.md | 3 +- packages/nest/README.md | 3 +- packages/next/README.md | 3 +- packages/node/README.md | 3 +- packages/openapi/README.md | 3 +- packages/opentelemetry/README.md | 3 +- packages/pinia-colada/README.md | 3 +- packages/pino/README.md | 3 +- packages/publisher/README.md | 3 +- packages/ratelimit/README.md | 3 +- packages/server/README.md | 3 +- packages/server/src/procedure-client.test.ts | 8 +- packages/server/src/procedure-client.ts | 11 +- packages/shared/README.md | 3 +- packages/swr/README.md | 3 +- packages/tanstack-query/README.md | 3 +- packages/trpc/README.md | 3 +- packages/valibot/README.md | 3 +- packages/zod/README.md | 3 +- pnpm-lock.yaml | 121 ++++- 63 files changed, 3618 insertions(+), 53 deletions(-) create mode 100644 apps/content/docs/helpers/cache.mdx create mode 100644 packages/cache/README.md create mode 100644 packages/cache/package.json create mode 100644 packages/cache/src/adapters/memory.test.ts create mode 100644 packages/cache/src/adapters/memory.ts create mode 100644 packages/cache/src/adapters/output-serializer.ts create mode 100644 packages/cache/src/adapters/redis.test.ts create mode 100644 packages/cache/src/adapters/redis.ts create mode 100644 packages/cache/src/adapters/vercel.test.ts create mode 100644 packages/cache/src/adapters/vercel.ts create mode 100644 packages/cache/src/handler-plugin.test.ts create mode 100644 packages/cache/src/handler-plugin.ts create mode 100644 packages/cache/src/index.test.ts create mode 100644 packages/cache/src/index.ts create mode 100644 packages/cache/src/middleware.test-d.ts create mode 100644 packages/cache/src/middleware.test.ts create mode 100644 packages/cache/src/middleware.ts create mode 100644 packages/cache/src/types.ts create mode 100644 packages/cache/tests/e2e.test.ts create mode 100644 packages/cache/tsconfig.json create mode 100644 packages/cloudflare/src/cache-api.test.ts create mode 100644 packages/cloudflare/src/cache-api.ts create mode 100644 packages/cloudflare/src/cache.test.ts create mode 100644 packages/cloudflare/src/cache.ts create mode 100644 packages/cloudflare/src/workers-cache.test.ts create mode 100644 packages/cloudflare/src/workers-cache.ts diff --git a/README.md b/README.md index c958af360..12ed79373 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index b791a77b5..65e8107f7 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -32,6 +32,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | Package | Purpose | Related Guides | | ------- | ------- | -------------- | +| [@orpc/cache](https://npmx.dev/package-docs/@orpc/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) | @@ -50,7 +51,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) | Cloudflare KV, Cache API, Workers Caching, Durable Object, and Rate Limit adapters. | [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 diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx new file mode 100644 index 000000000..6b5b290ef --- /dev/null +++ b/apps/content/docs/helpers/cache.mdx @@ -0,0 +1,317 @@ +--- +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/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/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) | +| `CloudflareKVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | +| `CloudflareCacheApiCacheStore` | [Cloudflare Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) with [tag purging](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/) | +| `CloudflareWorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | + + + +```ts memory +import { MemoryCacheStore } from '@orpc/cache/memory' + +const store = new MemoryCacheStore() +``` + +```ts redis +import { RedisCacheStore } from '@orpc/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(client, { + /** + * The prefix to use for Redis keys. + * + * @default 'orpc:cache:' + */ + prefix: 'orpc:cache:', + + /** + * Serializer for cached outputs. The default preserves Date, BigInt, + * Set, Map, URL, RegExp, NaN, and undefined values, and rejects + * outputs containing Blob or File values. + */ + serializer: undefined, +}) +``` + +```ts vercel +import { VercelCacheStore } from '@orpc/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. The default preserves Date, BigInt, + * Set, Map, URL, RegExp, NaN, and undefined values, and rejects + * outputs containing Blob or File values. + */ + serializer: undefined, +}) +``` + +```ts cloudflare-kv +import { CloudflareKVCacheStore } 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 CloudflareKVCacheStore(env.CACHE_KV, { + /** + * The prefix to use for KV keys. + * + * @default 'orpc:cache:' + */ + prefix: 'orpc:cache:', + + /** + * Serializer for cached outputs. The default preserves Date, BigInt, + * Set, Map, URL, RegExp, NaN, and undefined values, and rejects + * outputs containing Blob or File values. + */ + serializer: undefined, + }) + }, +} +``` + +```ts cloudflare-cache-api +import { CloudflareCacheApiCacheStore } from '@orpc/cloudflare' + +export default { + async fetch(request, env) { + // Entries are cached per data center, while revalidations + // purge their tags zone-wide through Cloudflare's Instant Purge. + const store = new CloudflareCacheApiCacheStore({ + /** + * An URL under your zone used to derive the synthetic cache keys. + */ + baseUrl: 'https://example.com/__orpc/cache', + + /** + * The zone ID used for tag purges. + */ + zoneId: env.CF_ZONE_ID, + + /** + * An API token with the Zone > Cache Purge permission. + */ + apiToken: env.CF_API_TOKEN, + + /** + * The Cache API instance to store entries in. + * + * @default caches.default + */ + cache: caches.default, + + /** + * Serializer for cached outputs. The default preserves Date, BigInt, + * Set, Map, URL, RegExp, NaN, and undefined values, and rejects + * outputs containing Blob or File values. + */ + serializer: undefined, + }) + }, +} +``` + +```ts cloudflare-workers-caching +import { CloudflareWorkersCacheStore } from '@orpc/cloudflare' + +export default { + async fetch(request, env, ctx) { + // Workers Caching caches whole responses in front of the Worker via the + // `httpCacheHeaders` option of the handler plugin; 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 CloudflareWorkersCacheStore(ctx.cache) + }, +} +``` + + + +## 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/cache' +import { MemoryCacheStore } from '@orpc/cache/memory' + +const findPlanet = os + .$context() + .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/cache' + +const updatePlanet = os + .$context() + .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 cache activity in HTTP response headers when used with [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware): + +- `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. + +Tags are joined with commas. Only `%`, `,`, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Use `decodeCacheTagHeader` to parse a header back into tags. + +```ts +import { CacheHandlerPlugin } from '@orpc/cache' + +const handler = new RPCHandler(router, { + plugins: [ + new CacheHandlerPlugin({ + /** + * Also reflect the root cache check into `Cache-Control` and `Cache-Tag` + * headers on GET and HEAD responses, so response caches in front, such + * as CDNs or Cloudflare Workers Caching, can serve and purge whole + * responses. Headers already present are never overridden. + * + * @default false + */ + httpCacheHeaders: false, + }), + ], +}) +``` + +:::info[Response Caches in Front] +With `httpCacheHeaders` enabled, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `CloudflareWorkersCacheStore`, 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. +::: diff --git a/apps/content/docs/procedure.mdx b/apps/content/docs/procedure.mdx index 34090cdd0..cb8b06a39 100644 --- a/apps/content/docs/procedure.mdx +++ b/apps/content/docs/procedure.mdx @@ -70,7 +70,7 @@ By specifying `.output` or the handler's return type, TypeScript can infer the o `.input` and `.output` can be called multiple times. Each call adds a schema instead of replacing the previous one, and the value must pass all of them. -Every `.input` object schema validates the original input, then the results are merged. This lets you extend a base procedure without repeating its fields. Fields that no schema defines are dropped. [Middleware](/docs/middleware) sees the fields validated before it, the handler sees them all. +Every `.input` object schema validates the original input, then the results are merged. This lets you extend a base procedure without repeating its fields. Fields that no schema defines are dropped for the handler. [Middleware](/docs/middleware) receives the full input with the fields validated before it merged over the original, though its input type only reflects the validated ones. ```ts const base = os diff --git a/apps/content/package.json b/apps/content/package.json index 513aae952..b4834aaa1 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -13,6 +13,7 @@ "@opentelemetry/sdk-node": "^0.221.0", "@opentelemetry/sdk-trace-web": "^2.10.0", "@orpc/arktype": "workspace:*", + "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", diff --git a/package.json b/package.json index 3c85dbf8a..f4b27ee4b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@hono/node-server": "^2.1.0", "@opentelemetry/api": "^1.9.1", "@orpc/arktype": "workspace:*", + "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 8617cbf10..56db0e484 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index da6fc41f3..5cceebaff 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/bun/README.md b/packages/bun/README.md index 47701d7a6..71a5c1d53 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -41,6 +41,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -55,7 +56,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cache/README.md b/packages/cache/README.md new file mode 100644 index 000000000..cc61a5320 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,165 @@ +

oRPC - Typesafe APIs Made Simple πŸͺ„

+ +
+ + codecov + + + weekly downloads + + + CodSpeed + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://npmx.dev/package/@orpc/contract): Define API contract as the single source of truth. +- [@orpc/server](https://npmx.dev/package/@orpc/server): Build APIs or implement contracts. +- [@orpc/client](https://npmx.dev/package/@orpc/client): Consume APIs with end-to-end type safety. +- [@orpc/openapi](https://npmx.dev/package/@orpc/openapi): Add OpenAPI compatibility to APIs. + +**Schema validation** + +- [@orpc/zod](https://npmx.dev/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://npmx.dev/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://npmx.dev/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Built-in features** + +- [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. +- [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. +- [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Framework & ecosystem integrations** + +- [@orpc/next](https://npmx.dev/package/@orpc/next): Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/ai-sdk](https://npmx.dev/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. +- [@orpc/tanstack-query](https://npmx.dev/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/pinia-colada](https://npmx.dev/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://npmx.dev/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). +- [@orpc/experimental-effect](https://npmx.dev/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). +- [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. +- [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. + +**Observability** + +- [@orpc/opentelemetry](https://npmx.dev/package/@orpc/opentelemetry): Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. +- [@orpc/pino](https://npmx.dev/package/@orpc/pino): Integrate with [Pino](https://getpino.io/) for logging. +- [@orpc/evlog](https://npmx.dev/package/@orpc/evlog): Integrate with [Evlog](https://evlog.dev/) for logging. + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! πŸš€ + +### πŸ† Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### πŸ₯ˆ Silver Sponsor + + + + + +
ζ‘δΈŠγ•γ‚“
ζ‘δΈŠγ•γ‚“
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
あわわわとーにゅ
あわわわとーにゅ
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
christ12938
christ12938
Ryan Soderberg
Ryan Soderberg
shota
shota
Ellis Driscoll
Ellis Driscoll
Hoang Nguyen
Hoang Nguyen
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
Robbe Vaes
Robbe Vaes
Aidan Sunbury
Aidan Sunbury
soonoo
soonoo
Kevin Porten
Kevin Porten
Denis
Denis
Christopher Kapic
Christopher Kapic
Tom Ballinger
Tom Ballinger
Sam
Sam
Titoine
Titoine
Igor Makowski
Igor Makowski
hanayashiki
hanayashiki
Lev Dubinets
Lev Dubinets
Kelly Peilin Chan
Kelly Peilin Chan
Guy Ariely
Guy Ariely
Alex
Alex
Andrey Gubanov
Andrey Gubanov
+ +With thanks to 37 past sponsors who helped get oRPC here. + +## References + +oRPC is inspired by existing solutions that prioritize type safety and developer experience. Special acknowledgments to: + +- [tRPC](https://trpc.io): For pioneering the concept of end-to-end type-safe RPC and influencing the development of type-safe APIs. +- [ts-rest](https://ts-rest.com): For its emphasis on contract-first development and OpenAPI integration, which have greatly inspired oRPC's feature set. + +## License + +Distributed under the MIT License. See [LICENSE](https://github.com/middleapi/orpc/blob/main/LICENSE) for more information. diff --git a/packages/cache/package.json b/packages/cache/package.json new file mode 100644 index 000000000..2ee521a14 --- /dev/null +++ b/packages/cache/package.json @@ -0,0 +1,79 @@ +{ + "name": "@orpc/cache", + "type": "module", + "version": "2.0.0-beta.28", + "license": "MIT", + "funding": "https://github.com/sponsors/dinwwwh", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/cache" + }, + "keywords": [ + "orpc", + "cache" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./memory": { + "types": "./dist/adapters/memory.d.mts", + "import": "./dist/adapters/memory.mjs", + "default": "./dist/adapters/memory.mjs" + }, + "./redis": { + "types": "./dist/adapters/redis.d.mts", + "import": "./dist/adapters/redis.mjs", + "default": "./dist/adapters/redis.mjs" + }, + "./vercel": { + "types": "./dist/adapters/vercel.d.mts", + "import": "./dist/adapters/vercel.mjs", + "default": "./dist/adapters/vercel.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./memory": "./src/adapters/memory.ts", + "./redis": "./src/adapters/redis.ts", + "./vercel": "./src/adapters/vercel.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "@vercel/functions": ">=2.1.0", + "redis": ">=6.0.0" + }, + "peerDependenciesMeta": { + "@vercel/functions": { + "optional": true + }, + "redis": { + "optional": true + } + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standardserver/core": "^0.8.0" + }, + "devDependencies": { + "@vercel/functions": "^3.9.5", + "redis": "^6.2.1" + } +} diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts new file mode 100644 index 000000000..7a7a44320 --- /dev/null +++ b/packages/cache/src/adapters/memory.test.ts @@ -0,0 +1,102 @@ +import { MemoryCacheStore } from './memory' + +describe('memoryCacheStore', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('round-trips outputs, including undefined', async () => { + const store = new MemoryCacheStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'] }) + await expect(store.get('k')).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + + await store.set('u', undefined) + await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: [], expiresAt: undefined }) + }) + + it('misses on unknown keys', async () => { + const store = new MemoryCacheStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('returns fresh entries with a future expiresAt, then evicts at ttl without swr', async () => { + const store = new MemoryCacheStore() + + await store.set('k', 'v', { ttl: 1000 }) + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: [], expiresAt: 1000 }) + + vi.setSystemTime(999) + await expect(store.get('k')).resolves.toBeDefined() + + vi.setSystemTime(1000) + await expect(store.get('k')).resolves.toBeUndefined() + }) + + it('returns stale entries within the swr window, then evicts', async () => { + const store = new MemoryCacheStore() + + await store.set('k', 'v', { ttl: 1000, swr: 500 }) + + vi.setSystemTime(1200) // past ttl, within swr + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: [], expiresAt: 1000 }) + + vi.setSystemTime(1500) // past ttl + swr + await expect(store.get('k')).resolves.toBeUndefined() + }) + + it('invalidates fresh and stale entries by any of their tags', async () => { + const store = new MemoryCacheStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('stale', 'v', { tags: ['a'], ttl: 1000, swr: 500 }) + await store.set('other', 'v', { tags: ['c'] }) + + vi.setSystemTime(1200) // 'stale' is now stale + await store.revalidateTag('a') + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('stale')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + }) + + it('revalidates many tags at once', async () => { + const store = new MemoryCacheStore() + + await store.set('a', 'v', { tags: ['a'] }) + await store.set('b', 'v', { tags: ['b'] }) + + await store.revalidateTag(['a', 'b']) + + await expect(store.get('a')).resolves.toBeUndefined() + await expect(store.get('b')).resolves.toBeUndefined() + }) + + it('entries set after a revalidation remain valid', async () => { + const store = new MemoryCacheStore() + + await store.set('k', 'old', { tags: ['t'] }) + await store.revalidateTag('t') + await store.set('k', 'new', { tags: ['t'] }) + + await expect(store.get('k')).resolves.toEqual({ output: 'new', tags: ['t'], expiresAt: undefined }) + }) + + it('overwrites replace tags and expiry', async () => { + const store = new MemoryCacheStore() + + await store.set('k', 'old', { tags: ['old'], ttl: 1000 }) + await store.set('k', 'new', { tags: ['new'] }) + + await store.revalidateTag('old') + vi.setSystemTime(2000) + + await expect(store.get('k')).resolves.toEqual({ output: 'new', tags: ['new'], expiresAt: undefined }) + }) +}) diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts new file mode 100644 index 000000000..030851b63 --- /dev/null +++ b/packages/cache/src/adapters/memory.ts @@ -0,0 +1,73 @@ +import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' +import { toArray } from '@orpc/shared' + +interface MemoryCacheStoreEntry { + output: unknown + tags: readonly string[] + /** + * Tag version counters snapshotted at set time, index-aligned with `tags`. + */ + tagVersions: number[] + expiresAt: number | undefined + evictAt: number | undefined +} + +/** + * In-memory cache store with tag-based invalidation, intended for + * development, testing, and single-instance deployments. Expired and + * revalidated entries are removed lazily on the next `get` of their key. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class MemoryCacheStore implements CacheStore { + private readonly entries = new Map() + private readonly tagVersions = new Map() + + async get(key: string): Promise { + const entry = this.entries.get(key) + + if (!entry) { + return undefined + } + + if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { + this.entries.delete(key) + return undefined + } + + const revalidated = entry.tags.some( + (tag, index) => (this.tagVersions.get(tag) ?? 0) !== entry.tagVersions[index], + ) + + if (revalidated) { + this.entries.delete(key) + return undefined + } + + return { + output: entry.output, + tags: entry.tags, + expiresAt: entry.expiresAt, + } + } + + async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const tags = options?.tags ?? [] + const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined + + this.entries.set(key, { + output, + tags, + tagVersions: tags.map(tag => this.tagVersions.get(tag) ?? 0), + expiresAt, + evictAt, + }) + } + + async revalidateTag(tag: string | readonly string[]): Promise { + for (const t of toArray(tag)) { + this.tagVersions.set(t, (this.tagVersions.get(t) ?? 0) + 1) + } + } +} diff --git a/packages/cache/src/adapters/output-serializer.ts b/packages/cache/src/adapters/output-serializer.ts new file mode 100644 index 000000000..215f1e7b0 --- /dev/null +++ b/packages/cache/src/adapters/output-serializer.ts @@ -0,0 +1,42 @@ +import type { RPCJsonSerialization } from '@orpc/client' +import { RPCJsonSerializer } from '@orpc/client' +import { stringifyJSON } from '@orpc/shared' + +/** + * Serializes cached outputs to strings and back, used by cache store adapters. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export interface CacheOutputSerializer { + stringify(data: unknown): string + parse(text: string): unknown +} + +/** + * Creates the default output serializer for cache store adapters, backed by + * the RPC JSON serializer so Date, BigInt, Set, Map, URL, RegExp, NaN, and + * undefined values survive the round trip. Outputs containing Blob or File + * values are rejected with a TypeError naming `storeName`. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function createRpcJsonOutputSerializer(storeName: string): CacheOutputSerializer { + // Cached outputs should round-trip exactly, so undefined properties are kept + // even though the RPC protocol omits them over the wire. + const jsonSerializer = new RPCJsonSerializer({ omitUndefinedProperties: false }) + + return { + stringify(data) { + const { json, meta, maps, blobs } = jsonSerializer.serialize(data) + + if (blobs?.length) { + throw new TypeError(`${storeName} cannot cache outputs containing Blob or File values`) + } + + return stringifyJSON({ json, meta, maps }) + }, + parse(text) { + return jsonSerializer.deserialize(JSON.parse(text) as RPCJsonSerialization) + }, + } +} diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts new file mode 100644 index 000000000..26a01fe85 --- /dev/null +++ b/packages/cache/src/adapters/redis.test.ts @@ -0,0 +1,149 @@ +import { sleep } from '@orpc/shared' +import { createClient } from 'redis' +import { RedisCacheStore } from './redis' + +const REDIS_URL = process.env.REDIS_URL + +describe.concurrent('redis cache store integration', { + skip: !REDIS_URL, + timeout: 20_000, +}, async () => { + const redis = createClient({ + url: REDIS_URL, + }) + + beforeAll(async () => { + await redis.connect() + }) + + function createTestingStore( + options: ConstructorParameters[1] = {}, + ) { + const prefix = `orpc-redis-cache-store-${crypto.randomUUID()}:` + return { store: new RedisCacheStore(redis, { prefix, ...options }), prefix } + } + + it('round-trips outputs with tags and expiresAt', async () => { + const { store } = createTestingStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 10_000 }) + + const entry = await store.get('k') + expect(entry!.output).toEqual({ nested: [1, 2] }) + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(Date.now()) + }) + + it('misses on unknown keys', async () => { + const { store } = createTestingStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + const { store } = createTestingStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + nothing: undefined, + } + + await store.set('k', output) + + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('rejects outputs containing blobs', async () => { + const { store } = createTestingStore() + + await expect( + store.set('k', { file: new Blob(['x']) }), + ).rejects.toThrow('RedisCacheStore cannot cache outputs containing Blob or File values') + }) + + it('supports a custom serializer', async () => { + const serializer = { + stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), + parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), + } + const { store } = createTestingStore({ serializer }) + + await store.set('k', { a: 1 }) + + await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializer.stringify).toHaveBeenCalled() + expect(serializer.parse).toHaveBeenCalled() + }) + + it('evicts at ttl without swr, and serves stale within the swr window', async () => { + const { store } = createTestingStore() + + await store.set('no-swr', 'v', { ttl: 300 }) + await store.set('swr', 'v', { ttl: 300, swr: 10_000 }) + + await sleep(500) + + await expect(store.get('no-swr')).resolves.toBeUndefined() + + const stale = await store.get('swr') + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(Date.now()) + }) + + it('invalidates entries by any of their tags', async () => { + const { store } = createTestingStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidateTag('a') + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + }) + + it('revalidates many tags at once', async () => { + const { store } = createTestingStore() + + await store.set('a', 'v', { tags: ['a'] }) + await store.set('b', 'v', { tags: ['b'] }) + + await store.revalidateTag(['a', 'b']) + + await expect(store.get('a')).resolves.toBeUndefined() + await expect(store.get('b')).resolves.toBeUndefined() + }) + + it('entries set after a revalidation remain valid', async () => { + const { store } = createTestingStore() + + await store.set('k', 'old', { tags: ['t'] }) + await store.revalidateTag('t') + await store.set('k', 'new', { tags: ['t'] }) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'new' }) + }) + + it('stores entries and tag counters under the prefixed key families', async () => { + const { store, prefix } = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidateTag('t') + + await expect(redis.exists(`${prefix}entry:k`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}tag:t`)).resolves.toBe(1) + }) + + it('lazily connects a closed client', async () => { + const lazyRedis = createClient({ url: REDIS_URL }) + const store = new RedisCacheStore(lazyRedis, { prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) + + expect(lazyRedis.isOpen).toBe(false) + await expect(store.get('unknown')).resolves.toBeUndefined() + expect(lazyRedis.isOpen).toBe(true) + + await lazyRedis.destroy() + }) +}) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts new file mode 100644 index 000000000..e55b64568 --- /dev/null +++ b/packages/cache/src/adapters/redis.ts @@ -0,0 +1,156 @@ +import type { RedisClientType } from 'redis' +import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' +import type { CacheOutputSerializer } from './output-serializer' +import { stringifyJSON, toArray } from '@orpc/shared' +import { createRpcJsonOutputSerializer } from './output-serializer' + +export type RedisCacheStoreSerializer = CacheOutputSerializer + +interface RedisCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: string + tags: readonly string[] + /** + * Tag version counters snapshotted at set time. + */ + tagVersions: Record + expiresAt?: number | undefined +} + +export interface RedisCacheStoreOptions { + /** + * The prefix to use for Redis keys. + * + * @default 'orpc:cache:' + */ + prefix?: string + + /** + * Serializer for cached outputs. + * + * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + */ + serializer?: RedisCacheStoreSerializer +} + +/** + * Cache store adapter for Redis with tag-based invalidation. Entries are + * retained for `ttl + swr` via `PX` expiry; tag counters have no expiry + * since expiring one would resurrect stale entries. Revalidated entries + * are removed lazily on the next `get` of their key. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class RedisCacheStore implements CacheStore { + private readonly redis: RedisClientType + private readonly prefix: string + private readonly serializer: RedisCacheStoreSerializer + + constructor( + redis: RedisClientType, + options: RedisCacheStoreOptions = {}, + ) { + this.redis = redis + this.prefix = options.prefix ?? 'orpc:cache:' + this.serializer = options.serializer ?? createRpcJsonOutputSerializer('RedisCacheStore') + } + + async get(key: string): Promise { + await this.ensureConnection() + + const raw = await this.redis.get(this.entryKey(key)) + + if (raw === null) { + return undefined + } + + const envelope = JSON.parse(raw.toString()) as RedisCacheStoreEnvelope + + if (envelope.tags.length) { + const versions = await this.redis.mGet(envelope.tags.map(tag => this.tagKey(tag))) + + const revalidated = envelope.tags.some( + (tag, index) => Number(versions[index] ?? 0) !== (envelope.tagVersions[tag] ?? 0), + ) + + if (revalidated) { + await this.redis.del(this.entryKey(key)) + return undefined + } + } + + return { + output: this.serializer.parse(envelope.output), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + await this.ensureConnection() + + const tags = options?.tags ?? [] + const serialized = this.serializer.stringify(output) + + const tagVersions: Record = {} + if (tags.length) { + const versions = await this.redis.mGet(tags.map(tag => this.tagKey(tag))) + tags.forEach((tag, index) => { + tagVersions[tag] = Number(versions[index] ?? 0) + }) + } + + const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + + const envelope: RedisCacheStoreEnvelope = { + output: serialized, + tags, + tagVersions, + expiresAt, + } + + await this.redis.set( + this.entryKey(key), + stringifyJSON(envelope), + retention !== undefined ? { expiration: { type: 'PX', value: retention } } : undefined, + ) + } + + async revalidateTag(tag: string | readonly string[]): Promise { + await this.ensureConnection() + + const tags = toArray(tag) + + if (!tags.length) { + return + } + + if (tags.length === 1) { + await this.redis.incr(this.tagKey(tags[0]!)) + return + } + + const multi = this.redis.multi() + for (const t of tags) { + multi.incr(this.tagKey(t)) + } + await multi.exec() + } + + private entryKey(key: string): string { + return `${this.prefix}entry:${key}` + } + + private tagKey(tag: string): string { + return `${this.prefix}tag:${tag}` + } + + private async ensureConnection(): Promise { + if (!this.redis.isOpen) { + await this.redis.connect() + } + } +} diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts new file mode 100644 index 000000000..38c0da7f7 --- /dev/null +++ b/packages/cache/src/adapters/vercel.test.ts @@ -0,0 +1,151 @@ +import type { RuntimeCache } from '@vercel/functions' +import { getCache } from '@vercel/functions' +import { VercelCacheStore } from './vercel' + +describe('vercelCacheStore', () => { + describe('against the in-memory getCache fallback', () => { + function createTestingStore() { + return new VercelCacheStore({ + cache: getCache({ namespace: crypto.randomUUID() }), + }) + } + + it('round-trips outputs with tags, including undefined', async () => { + const store = createTestingStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'] }) + await expect(store.get('k')).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + + await store.set('u', undefined) + await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: [], expiresAt: undefined }) + }) + + it('misses on unknown keys', async () => { + const store = createTestingStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + const store = createTestingStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + nothing: undefined, + } + + await store.set('k', output) + + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('rejects outputs containing blobs', async () => { + const store = createTestingStore() + + await expect( + store.set('k', { file: new Blob(['x']) }), + ).rejects.toThrow('VercelCacheStore cannot cache outputs containing Blob or File values') + }) + + it('invalidates entries by any of their tags via expireTag', async () => { + const store = createTestingStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidateTag('a') + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + }) + + it('revalidates many tags at once', async () => { + const store = createTestingStore() + + await store.set('a', 'v', { tags: ['a'] }) + await store.set('b', 'v', { tags: ['b'] }) + + await store.revalidateTag(['a', 'b']) + + await expect(store.get('a')).resolves.toBeUndefined() + await expect(store.get('b')).resolves.toBeUndefined() + }) + }) + + describe('against a mocked runtime cache', () => { + function createMockedCache() { + const values = new Map() + + const cache = { + get: vi.fn(async (key: string) => values.get(key) ?? null), + set: vi.fn(async (key: string, value: unknown) => { + values.set(key, value) + }), + delete: vi.fn(async (key: string) => { + values.delete(key) + }), + expireTag: vi.fn(async () => {}), + } satisfies RuntimeCache + + return cache + } + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('maps ttl + swr to whole-second retention', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.set('k', 'v', { tags: ['t'], ttl: 1000, swr: 500 }) + + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1000, evictAt: 1500 }), { tags: ['t'], ttl: 2 }) + }) + + it('omits ttl and tags options when unset', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.set('k', 'v') + + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: [] }), {}) + }) + + it('returns stale entries within the swr window, then evicts defensively', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.set('k', 'v', { ttl: 1000, swr: 500 }) + + vi.setSystemTime(1200) // past ttl, within swr + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: [], expiresAt: 1000 }) + + vi.setSystemTime(1500) // past ttl + swr, backend has not evicted yet + await expect(store.get('k')).resolves.toBeUndefined() + expect(cache.delete).toHaveBeenCalledWith('k') + }) + + it('supports a custom serializer', async () => { + const cache = createMockedCache() + const serializer = { + stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), + parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), + } + const store = new VercelCacheStore({ cache, serializer }) + + await store.set('k', { a: 1 }) + + await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializer.stringify).toHaveBeenCalled() + expect(serializer.parse).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts new file mode 100644 index 000000000..d06af8210 --- /dev/null +++ b/packages/cache/src/adapters/vercel.ts @@ -0,0 +1,100 @@ +import type { RuntimeCache } from '@vercel/functions' +import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' +import type { CacheOutputSerializer } from './output-serializer' +import { toArray } from '@orpc/shared' +import { getCache } from '@vercel/functions' +import { createRpcJsonOutputSerializer } from './output-serializer' + +export type VercelCacheStoreSerializer = CacheOutputSerializer + +interface VercelCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: string + tags: readonly string[] + expiresAt?: number | undefined + evictAt?: number | undefined +} + +export interface VercelCacheStoreOptions { + /** + * The Vercel Runtime Cache to use. + * + * @default getCache() + */ + cache?: RuntimeCache + + /** + * Serializer for cached outputs. + * + * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + */ + serializer?: VercelCacheStoreSerializer +} + +/** + * Cache store adapter for the Vercel Runtime Cache. Tags are expired + * natively via `expireTag`, and entries are retained for `ttl + swr` + * rounded up to whole seconds. Outside Vercel, the default `getCache()` + * falls back to an in-memory cache. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class VercelCacheStore implements CacheStore { + private readonly cache: RuntimeCache + private readonly serializer: VercelCacheStoreSerializer + + constructor(options: VercelCacheStoreOptions = {}) { + this.cache = options.cache ?? getCache() + this.serializer = options.serializer ?? createRpcJsonOutputSerializer('VercelCacheStore') + } + + async get(key: string): Promise { + const envelope = await this.cache.get(key) as VercelCacheStoreEnvelope | null | undefined + + if (envelope == null) { + return undefined + } + + if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { + await this.cache.delete(key) + return undefined + } + + return { + output: this.serializer.parse(envelope.output), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const tags = options?.tags ?? [] + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const evictAt = retention !== undefined ? Date.now() + retention : undefined + + const envelope: VercelCacheStoreEnvelope = { + output: this.serializer.stringify(output), + tags, + expiresAt, + evictAt, + } + + await this.cache.set(key, envelope, { + ...(tags.length ? { tags: [...tags] } : {}), + ...(retention !== undefined ? { ttl: Math.ceil(retention / 1000) } : {}), + }) + } + + async revalidateTag(tag: string | readonly string[]): Promise { + const tags = toArray(tag) + + if (!tags.length) { + return + } + + await this.cache.expireTag([...tags]) + } +} diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts new file mode 100644 index 000000000..8c44dde5c --- /dev/null +++ b/packages/cache/src/handler-plugin.test.ts @@ -0,0 +1,272 @@ +import type { CacheContext } from './types' +import { call, ORPCError, os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import { MemoryCacheStore } from './adapters/memory' +import { + CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL, + CACHE_TAG_HEADER, + CACHE_TAG_INVALIDATION_HEADER, + CacheHandlerPlugin, + decodeCacheTagHeader, + encodeCacheTagHeader, +} from './handler-plugin' +import { cache, revalidate } from './middleware' + +describe('cacheHandlerPlugin', () => { + const handlerFn = vi.fn() + const procedure = os.handler(handlerFn) + const handler = new RPCHandler(procedure, { + allowMethods: ['GET'], // tests below send GET requests + plugins: [ + new CacheHandlerPlugin(), + ], + }) + + afterEach(() => { + handlerFn.mockReset() + }) + + it('reflects cache tags from the first check of the called procedure', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets', 'planet:1'] }, + { path, procedure, hit: true, stale: false, key: 'k2', tags: ['ignored'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets,planet:1') + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + }) + + it('reflects invalidation tags from the first revalidation of the called procedure', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].revalidations.push( + { path, procedure, tags: ['planets'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('planets') + }) + + it('reflects both headers when both kinds of checks ran', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: true, stale: false, key: 'k', tags: ['a'] }, + ) + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].revalidations.push( + { path, procedure, tags: ['b'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('a') + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('b') + }) + + it('ignores checks recorded for other procedures or paths', async () => { + const other = os.handler(() => 'other') + + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure: other, hit: false, stale: false, key: 'k', tags: ['other-procedure'] }, + { path: [...path, 'nested'], procedure, hit: false, stale: false, key: 'k', tags: ['other-path'] }, + ) + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].revalidations.push( + { path, procedure: other, tags: ['other-procedure'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + }) + + it('skips headers when no checks ran or tags are empty', async () => { + const { response: noChecks } = await handler.handle(new Request('http://localhost:3000')) + + expect(noChecks!.headers.get(CACHE_TAG_HEADER)).toBe(null) + expect(noChecks!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: [] }, + ) + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].revalidations.push( + { path, procedure, tags: [] }, + ) + }) + + const { response: emptyTags } = await handler.handle(new Request('http://localhost:3000')) + + expect(emptyTags!.headers.get(CACHE_TAG_HEADER)).toBe(null) + expect(emptyTags!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + }) + + it('skips headers on error responses', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets'] }, + ) + + throw new ORPCError('INTERNAL_SERVER_ERROR') + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.status).toBe(500) + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) + }) + + it('percent-encodes tags containing special characters', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['a,b', 'tiαΊΏng việt'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + const header = response!.headers.get(CACHE_TAG_HEADER)! + expect(header).toBe('a%2Cb,ti%E1%BA%BFng%20vi%E1%BB%87t') + expect(decodeCacheTagHeader(header)).toEqual(['a,b', 'tiαΊΏng việt']) + }) + + it('only reflects the tags of the procedure the client called in nested calls', async () => { + const store = new MemoryCacheStore() + + const inner = os + .$context() + .use(cache({ key: 'inner', tags: ['inner-tag'] })) + .use(revalidate('inner-revalidated')) + .handler(() => 'inner') + + const outer = os + .$context() + .use(cache({ key: 'outer', tags: ['outer-tag'] })) + .use(revalidate('outer-revalidated')) + .handler(async ({ context }) => `outer:${await call(inner, undefined, { context })}`) + + const nestedHandler = new RPCHandler({ outer, inner }, { + allowMethods: ['GET'], + plugins: [new CacheHandlerPlugin()], + }) + + const { response } = await nestedHandler.handle(new Request('http://localhost:3000/outer'), { + context: { cache: store }, + }) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('outer-tag') + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('outer-revalidated') + }) +}) + +describe('cacheHandlerPlugin httpCacheHeaders', () => { + const handlerFn = vi.fn() + const procedure = os.handler(handlerFn) + const handler = new RPCHandler(procedure, { + allowMethods: ['GET', 'POST'], + plugins: [ + new CacheHandlerPlugin({ httpCacheHeaders: true }), + ], + }) + + afterEach(() => { + handlerFn.mockReset() + }) + + it('reflects the root cache check into Cache-Tag and Cache-Control on GET responses', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets', 'a,b'], ttl: 1500, swr: 500 }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets,a%2Cb') + expect(response!.headers.get('cache-tag')).toBe('planets,a%2Cb') + expect(response!.headers.get('cache-control')).toBe('public, s-maxage=2, stale-while-revalidate=1') + }) + + it('holds entries without a ttl for a year, and skips Cache-Tag without tags', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: [] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get('cache-tag')).toBe(null) + expect(response!.headers.get('cache-control')).toBe('public, s-maxage=31536000') + }) + + it('skips HTTP caching headers on non-GET requests', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets'], ttl: 1500 }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + })) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets') + expect(response!.headers.get('cache-tag')).toBe(null) + expect(response!.headers.get('cache-control')).toBe(null) + }) + + it('skips HTTP caching headers without a root cache check', async () => { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].revalidations.push( + { path, procedure, tags: ['planets'] }, + ) + }) + + const { response } = await handler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('planets') + expect(response!.headers.get('cache-control')).toBe(null) + }) + + it('never emits HTTP caching headers by default', async () => { + const defaultHandler = new RPCHandler(procedure, { + allowMethods: ['GET'], + plugins: [new CacheHandlerPlugin()], + }) + + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( + { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets'], ttl: 1500 }, + ) + }) + + const { response } = await defaultHandler.handle(new Request('http://localhost:3000')) + + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets') + expect(response!.headers.get('cache-tag')).toBe(null) + expect(response!.headers.get('cache-control')).toBe(null) + }) +}) + +describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { + it('round-trips tags with commas, percents, and unicode', () => { + const tags = ['plain', 'a,b', '100%', 'tiαΊΏng việt', 'sp ace'] + + expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) + }) + + it('decodes empty headers to no tags', () => { + expect(decodeCacheTagHeader('')).toEqual([]) + }) +}) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts new file mode 100644 index 000000000..b16a02e6f --- /dev/null +++ b/packages/cache/src/handler-plugin.ts @@ -0,0 +1,155 @@ +import type { AnyProcedure, Context } from '@orpc/server' +import type { StandardHandlerInterceptor, StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardHeaders } from '@standardserver/core' +import { isDeepEqual, toArray, tryDecodeURIComponent } from '@orpc/shared' + +export const CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL: unique symbol = Symbol.for('ORPC_CACHE_HANDLER_PLUGIN_CONTEXT') + +export interface CacheHandlerPluginContext { + [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]?: { + /** + * The cache lookups performed during this request, both hits and stores. + * `ttl` carries the remaining freshness in milliseconds on hits and the + * resolved fresh lifetime on stores. + */ + caches: { procedure: AnyProcedure, path: string[], hit: boolean, stale: boolean, key: string, tags: readonly string[], ttl?: number | undefined, swr?: number | undefined }[] + + /** + * The tag revalidations committed during this request. + */ + revalidations: { procedure: AnyProcedure, path: string[], tags: readonly string[] }[] + } +} + +/** + * The response header carrying the tags the cached response depends on. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export const CACHE_TAG_HEADER = 'orpc-cache-tag' + +/** + * The response header carrying the tags revalidated by the request, + * useful for invalidating tagged data in client caches. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export const CACHE_TAG_INVALIDATION_HEADER = 'orpc-cache-tag-invalidation' + +/** + * Encodes cache tags into a header value: tags are joined with commas, and + * only `%`, `,`, and characters that cannot appear in a header value + * (whitespace, control characters, non-ASCII) are percent-encoded, so + * typical tags stay readable. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function encodeCacheTagHeader(tags: readonly string[]): string { + return tags.map(tag => tag.replace(/[^\x21-\x7E]|[%,]/gu, c => encodeURIComponent(c))).join(',') +} + +/** + * Decodes a header value produced by {@link encodeCacheTagHeader} back into tags. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function decodeCacheTagHeader(header: string): string[] { + return header.split(',').filter(Boolean).map(tryDecodeURIComponent) +} + +export interface CacheHandlerPluginOptions { + /** + * Also reflects the root cache check into standard HTTP caching headers on + * GET and HEAD responses: `Cache-Tag` with the same encoded tags, and + * `Cache-Control: public, s-maxage=...` (plus `stale-while-revalidate` when + * `swr` is set) derived from the check's freshness. Headers already present + * on the response are never overridden. This lets response caches in front, + * such as CDNs or Cloudflare Workers Caching, serve and purge whole responses. + * + * @default false + */ + httpCacheHeaders?: boolean +} + +/** + * Reflects cache tags and revalidated tags into the `orpc-cache-tag` and + * `orpc-cache-tag-invalidation` response headers when used with the `cache` and + * `revalidate` middlewares. Only the first check belonging to the procedure + * the client called is reflected, so nested procedure calls never leak + * their tags into the response. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export class CacheHandlerPlugin implements StandardHandlerPlugin { + name = '~cache' + + private readonly httpCacheHeaders: boolean + + constructor(options: CacheHandlerPluginOptions = {}) { + this.httpCacheHeaders = options.httpCacheHeaders ?? false + } + + init(options: StandardHandlerOptions): StandardHandlerOptions { + const interceptor: StandardHandlerInterceptor = async (interceptorOptions) => { + const pluginContext: Exclude = { caches: [], revalidations: [] } + + const response = await interceptorOptions.next({ + ...interceptorOptions, + context: { + ...interceptorOptions.context, + [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext, + } satisfies CacheHandlerPluginContext, + }) + + const rootCache = pluginContext.caches.find( + check => check.procedure === interceptorOptions.procedure && isDeepEqual(check.path, interceptorOptions.path), + ) + const rootRevalidation = pluginContext.revalidations.find( + check => check.procedure === interceptorOptions.procedure && isDeepEqual(check.path, interceptorOptions.path), + ) + + const method = interceptorOptions.request.method.toUpperCase() + const emitHttpHeaders = this.httpCacheHeaders && rootCache !== undefined && (method === 'GET' || method === 'HEAD') + + if (!rootCache?.tags.length && !rootRevalidation?.tags.length && !emitHttpHeaders) { + return response + } + + const headers: StandardHeaders = { ...response.headers } + + if (rootCache?.tags.length) { + headers[CACHE_TAG_HEADER] = encodeCacheTagHeader(rootCache.tags) + } + + if (rootRevalidation?.tags.length) { + headers[CACHE_TAG_INVALIDATION_HEADER] = encodeCacheTagHeader(rootRevalidation.tags) + } + + if (emitHttpHeaders) { + if (headers['cache-tag'] === undefined && rootCache.tags.length) { + headers['cache-tag'] = encodeCacheTagHeader(rootCache.tags) + } + + if (headers['cache-control'] === undefined) { + /** + * Entries without a ttl stay valid until revalidated, so front caches + * hold them for a year and rely on tag purges. + */ + const sMaxAge = rootCache.ttl !== undefined ? Math.ceil(rootCache.ttl / 1000) : 31536000 + const staleWhileRevalidate = rootCache.swr !== undefined && rootCache.swr > 0 ? `, stale-while-revalidate=${Math.ceil(rootCache.swr / 1000)}` : '' + headers['cache-control'] = `public, s-maxage=${sMaxAge}${staleWhileRevalidate}` + } + } + + return { ...response, headers } + } + + return { + ...options, + interceptors: [ + ...toArray(options.interceptors), + interceptor, + ], + } + } +} diff --git a/packages/cache/src/index.test.ts b/packages/cache/src/index.test.ts new file mode 100644 index 000000000..3a9c1cffa --- /dev/null +++ b/packages/cache/src/index.test.ts @@ -0,0 +1,11 @@ +it('exports plugin, middleware factories, and header helpers', async () => { + await expect(import('./index')).resolves.toMatchObject({ + CacheHandlerPlugin: expect.any(Function), + cache: expect.any(Function), + revalidate: expect.any(Function), + encodeCacheTagHeader: expect.any(Function), + decodeCacheTagHeader: expect.any(Function), + CACHE_TAG_HEADER: 'orpc-cache-tag', + CACHE_TAG_INVALIDATION_HEADER: 'orpc-cache-tag-invalidation', + }) +}) diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts new file mode 100644 index 000000000..86bde1da3 --- /dev/null +++ b/packages/cache/src/index.ts @@ -0,0 +1,4 @@ +export * from './adapters/output-serializer' +export * from './handler-plugin' +export * from './middleware' +export * from './types' diff --git a/packages/cache/src/middleware.test-d.ts b/packages/cache/src/middleware.test-d.ts new file mode 100644 index 000000000..df439e9fb --- /dev/null +++ b/packages/cache/src/middleware.test-d.ts @@ -0,0 +1,121 @@ +import type { CacheContext, CacheStore } from './types' +import { os, type } from '@orpc/server' +import { cache, revalidate } from './middleware' + +describe('cache', () => { + it('can infer context & input types', () => { + os + .$context<{ userId: string, cache: CacheStore }>() + .input(type<{ id: number }>()) + .use(({ next }) => { + return next({ + context: { + db: 'postgres', + }, + }) + }) + .use( + cache({ + key: async ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context.db).toBeString() + expectTypeOf(context.cache).toEqualTypeOf() + + return `planet:${input.id}` + }, + tags: ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context.db).toBeString() + + return [`planet:${input.id}`] + }, + ttl: ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + + return 1000 + }, + swr: ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + + return 500 + }, + enabled: ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + + return true + }, + }), + ) + .handler(({ context, input }) => { + expectTypeOf(context.cache).toEqualTypeOf() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context.db).toBeString() + expectTypeOf(input.id).toBeNumber() + + return 'ok' + }) + }) + + it('key is optional and accepts non-string material', () => { + const base = os.$context().input(type<{ id: number }>()) + + void base.use(cache()) + void base.use(cache({})) + void base.use(cache({ key: 'k' })) + void base.use(cache({ key: (_, input) => ({ id: input.id }) })) + }) + + it('requires the cache store to be declared in the initial context', () => { + void os.$context().use(cache({ key: 'k' })) + + // @ts-expect-error - initial context must provide the cache store + void os.use(cache({ key: 'k' })) + }) +}) + +describe('revalidate', () => { + it('can infer context & input types', () => { + os + .$context<{ userId: string, cache: CacheStore }>() + .input(type<{ id: number }>()) + .use( + revalidate(async ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context.cache).toEqualTypeOf() + + return `planet:${input.id}` + }), + ) + .handler(({ context, input }) => { + expectTypeOf(context.cache).toEqualTypeOf() + expectTypeOf(context.userId).toBeString() + expectTypeOf(input.id).toBeNumber() + + return 'ok' + }) + }) + + it('accepts a single tag, a non-empty tag list, but rejects an empty one', () => { + const base = os.$context() + + void base.use(revalidate('planets')) + void base.use(revalidate(['planets', 'planet:1'])) + void base.use(revalidate(() => ['planets'])) + + // @ts-expect-error - tags must not be empty + void base.use(revalidate([])) + }) + + it('requires the cache store to be declared in the initial context', () => { + void os.$context().use(revalidate('t')) + + // @ts-expect-error - initial context must provide the cache store + void os.use(revalidate('t')) + }) +}) diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts new file mode 100644 index 000000000..72f521827 --- /dev/null +++ b/packages/cache/src/middleware.test.ts @@ -0,0 +1,433 @@ +import type { CacheHandlerPluginContext } from './handler-plugin' +import type { CacheContext, CacheEntry, CacheStore } from './types' +import { call, os, type } from '@orpc/server' +import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' +import { cache, revalidate } from './middleware' + +function createStore(entry?: CacheEntry) { + return { + get: vi.fn().mockResolvedValue(entry), + set: vi.fn().mockResolvedValue(undefined), + revalidateTag: vi.fn().mockResolvedValue(undefined), + } +} + +describe('cache', () => { + it('runs the handler and stores the output on miss', async () => { + const store = createStore() + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t1', 't2'], ttl: 1000, swr: 500 })) + .handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('fresh') + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.get).toHaveBeenCalledWith('k') + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t1', 't2'], ttl: 1000, swr: 500 }) + }) + + describe('key derivation', () => { + it('derives the key from the procedure path and input by default', async () => { + const store = createStore() + const procedure = os.$context().input(type()).use(cache()).handler(() => 'ok') + + await call(procedure, { id: 1 }, { context: { cache: store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { cache: store }, path: ['planet', 'find'] }) + await call(procedure, { id: 2 }, { context: { cache: store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { cache: store }, path: ['user', 'find'] }) + + const keys = store.get.mock.calls.map(([key]) => key) + expect(keys.every(key => typeof key === 'string')).toBe(true) + expect(keys[0]).toBe(keys[1]) // same path + input + expect(keys[0]).not.toBe(keys[2]) // different input + expect(keys[0]).not.toBe(keys[3]) // different path + }) + + it('derives the key from non-string key material, and uses string keys verbatim', async () => { + const store = createStore() + const material = os + .$context() + .input(type()) + .use(cache({ key: (_, input) => ({ id: input.id }) })) + .handler(() => 'ok') + const verbatim = os.$context().use(cache({ key: 'k' })).handler(() => 'ok') + + await call(material, { id: 1, page: 1 }, { context: { cache: store }, path: ['planet', 'find'] }) + await call(material, { id: 1, page: 2 }, { context: { cache: store }, path: ['planet', 'find'] }) + await call(verbatim, undefined, { context: { cache: store } }) + + const keys = store.get.mock.calls.map(([key]) => key) + expect(keys[0]).toBe(keys[1]) // same material despite different inputs + expect(keys[2]).toBe('k') + }) + + it('derives the default key from the full input when input schemas are stacked', async () => { + const store = createStore() + const procedure = os + .$context() + .input(type<{ id: number }>(raw => ({ id: (raw as any).id }))) + .use(cache()) + .input(type<{ page: number }>(raw => ({ page: (raw as any).page }))) + .handler(() => 'ok') + + await call(procedure, { id: 1, page: 1 } as any, { context: { cache: store } }) + await call(procedure, { id: 1, page: 2 } as any, { context: { cache: store } }) + + // The middleware only validated `id` at its position, but the key still + // covers the full input, so different pages never share an entry. + const keys = store.get.mock.calls.map(([key]) => key) + expect(keys[0]).not.toBe(keys[1]) + }) + + it('rejects default keys for inputs containing blobs', async () => { + const store = createStore() + const procedure = os.$context().input(type()).use(cache()).handler(() => 'ok') + + await expect( + call(procedure, { file: new Blob(['x']) }, { context: { cache: store } }), + ).rejects.toThrow('Cache key material must not contain Blob or File values') + + expect(store.get).not.toHaveBeenCalled() + }) + }) + + it('short-circuits the handler on fresh hit', async () => { + const store = createStore({ output: 'cached', tags: ['t'], expiresAt: Date.now() + 1000 }) + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('cached') + + expect(handlerFn).not.toHaveBeenCalled() + expect(store.set).not.toHaveBeenCalled() + }) + + it('treats entries without expiresAt as always fresh', async () => { + const store = createStore({ output: 'cached', tags: [] }) + const handlerFn = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('cached') + + expect(handlerFn).not.toHaveBeenCalled() + }) + + it('serves cached undefined outputs', async () => { + const store = createStore({ output: undefined, tags: [] }) + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBeUndefined() + + expect(handlerFn).not.toHaveBeenCalled() + }) + + it('key, tags, ttl, swr, enabled can be async functions', async () => { + const store = createStore() + const keyFn = vi.fn().mockResolvedValueOnce('k') + const tagsFn = vi.fn().mockResolvedValueOnce(['t']) + const ttlFn = vi.fn().mockResolvedValueOnce(1000) + const swrFn = vi.fn().mockResolvedValueOnce(500) + const enabledFn = vi.fn().mockResolvedValueOnce(true) + const mw = cache({ key: keyFn, tags: tagsFn, ttl: ttlFn, swr: swrFn, enabled: enabledFn }) + const procedure = os + .$context() + .input(type()) + .use(mw) + .handler(() => 'ok') + + await expect( + call(procedure, '__input__', { context: { cache: store, __context__: true }, path: ['__path__'] }), + ).resolves.toBe('ok') + + expect(store.set).toHaveBeenCalledWith('k', 'ok', { tags: ['t'], ttl: 1000, swr: 500 }) + + for (const fn of [keyFn, tagsFn, ttlFn, swrFn, enabledFn]) { + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith( + expect.objectContaining({ procedure, path: ['__path__'], context: expect.objectContaining({ __context__: true }) }), + '__input__', + ) + } + }) + + it('skips lookup and store when enabled resolves to false', async () => { + const store = createStore() + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k', enabled: () => false })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('fresh') + + expect(store.get).not.toHaveBeenCalled() + expect(store.set).not.toHaveBeenCalled() + }) + + it.each<[string, () => any]>([ + ['async iterator', () => (async function* () {})()], + ['readable stream', () => new ReadableStream()], + ])('never stores %s outputs and records no check', async (_, handlerFn) => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'] })) + .handler(handlerFn) + + await call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + }) + + expect(store.set).not.toHaveBeenCalled() + expect(pluginContext.caches).toEqual([]) + }) + + it('records misses into the handler plugin context with option tags', async () => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'] })) + .handler(() => 'ok') + + await call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + path: ['__path__'], + }) + + expect(pluginContext.caches).toEqual([ + { procedure, path: ['__path__'], hit: false, stale: false, key: 'k', tags: ['t'] }, + ]) + }) + + it('records hits into the handler plugin context with the stored entry tags', async () => { + const store = createStore({ output: 'cached', tags: ['stored'], expiresAt: Date.now() + 1000 }) + const pluginContext: Exclude = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['optioned'] })) + .handler(() => 'ok') + + await call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + path: ['__path__'], + }) + + expect(pluginContext.caches).toEqual([ + expect.objectContaining({ procedure, path: ['__path__'], hit: true, stale: false, key: 'k', tags: ['stored'] }), + ]) + expect(pluginContext.caches[0]!.ttl).toBeGreaterThan(0) // the entry's remaining freshness + }) + + it('propagates store.get failures', async () => { + const store = createStore() + store.get.mockRejectedValueOnce(new Error('store down')) + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'ok') + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).rejects.toThrow('store down') + }) + + it('propagates store.set failures and records no check', async () => { + const store = createStore() + store.set.mockRejectedValueOnce(new Error('store down')) + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k' })) + .handler(() => 'ok') + + await expect( + call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + }), + ).rejects.toThrow('store down') + + expect(pluginContext.caches).toEqual([]) + }) + + describe('stale-while-revalidate', () => { + it('serves stale output and refreshes in the background via waitUntil', async () => { + const store = createStore({ output: 'stale', tags: ['t'], expiresAt: Date.now() - 1 }) + const handlerFn = vi.fn().mockReturnValue('fresh') + const waitUntil = vi.fn() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'], ttl: 1000, swr: 500 })) + .handler(handlerFn) + + await expect( + call(procedure, undefined, { + context: { cache: store, waitUntil, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + path: ['__path__'], + }), + ).resolves.toBe('stale') + + expect(pluginContext.caches).toEqual([ + { procedure, path: ['__path__'], hit: true, stale: true, key: 'k', tags: ['t'], ttl: 0, swr: 500 }, + ]) + + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t'], ttl: 1000, swr: 500 }) + }) + + it('refreshes in the background without waitUntil', async () => { + const store = createStore({ output: 'stale', tags: [], expiresAt: Date.now() - 1 }) + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('stale') + + await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: [], ttl: undefined, swr: undefined })) + }) + + it('swallows background refresh failures', async () => { + const store = createStore({ output: 'stale', tags: [], expiresAt: Date.now() - 1 }) + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => { + throw new Error('handler down') + }) + + await expect( + call(procedure, undefined, { context: { cache: store, waitUntil } }), + ).resolves.toBe('stale') + + await expect(waitUntil.mock.calls[0]![0]).resolves.toBeUndefined() + expect(store.set).not.toHaveBeenCalled() + }) + + it('never stores streaming outputs from background refreshes', async () => { + const store = createStore({ output: 'stale', tags: [], expiresAt: Date.now() - 1 }) + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => (async function* () {})()) + + await expect( + call(procedure, undefined, { context: { cache: store, waitUntil } }), + ).resolves.toBe('stale') + + await waitUntil.mock.calls[0]![0] + expect(store.set).not.toHaveBeenCalled() + }) + }) +}) + +describe('revalidate', () => { + it('revalidates tags after the handler succeeds', async () => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(revalidate('planets')) + .handler(() => 'ok') + + await expect( + call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + path: ['__path__'], + }), + ).resolves.toBe('ok') + + expect(store.revalidateTag).toHaveBeenCalledWith(['planets']) + expect(pluginContext.revalidations).toEqual([ + { procedure, path: ['__path__'], tags: ['planets'] }, + ]) + }) + + it('accepts an array of tags', async () => { + const store = createStore() + const procedure = os.$context().use(revalidate(['a', 'b'])).handler(() => 'ok') + + await call(procedure, undefined, { context: { cache: store } }) + + expect(store.revalidateTag).toHaveBeenCalledWith(['a', 'b']) + }) + + it('tags can be an async function', async () => { + const store = createStore() + const tagsFn = vi.fn().mockResolvedValueOnce(['t']) + const procedure = os + .$context() + .input(type()) + .use(revalidate(tagsFn)) + .handler(() => 'ok') + + await call(procedure, '__input__', { context: { cache: store, __context__: true }, path: ['__path__'] }) + + expect(tagsFn).toHaveBeenCalledTimes(1) + expect(tagsFn).toHaveBeenCalledWith( + expect.objectContaining({ procedure, path: ['__path__'], context: expect.objectContaining({ __context__: true }) }), + '__input__', + ) + }) + + it('skips the revalidation when the handler throws', async () => { + const store = createStore() + const procedure = os.$context().use(revalidate('planets')).handler(() => { + throw new Error('handler down') + }) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).rejects.toThrow('handler down') + + expect(store.revalidateTag).not.toHaveBeenCalled() + }) + + it('skips the revalidation and recording when tags resolve to empty', async () => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(revalidate(() => [] as unknown as [string, ...string[]])) + .handler(() => 'ok') + + await call(procedure, undefined, { + context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + }) + + expect(store.revalidateTag).not.toHaveBeenCalled() + expect(pluginContext.revalidations).toEqual([]) + }) +}) + +describe('cache + revalidate combined', () => { + it('revalidates before storing on miss, and skips the revalidation on hit', async () => { + const store = createStore() + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'] })) + .use(revalidate('t')) + .handler(() => 'ok') + + await call(procedure, undefined, { context: { cache: store } }) + + expect(store.revalidateTag).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledTimes(1) + expect(store.revalidateTag.mock.invocationCallOrder[0]!).toBeLessThan(store.set.mock.invocationCallOrder[0]!) + + store.get.mockResolvedValueOnce({ output: 'cached', tags: ['t'] }) + + await expect( + call(procedure, undefined, { context: { cache: store } }), + ).resolves.toBe('cached') + + expect(store.revalidateTag).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts new file mode 100644 index 000000000..ff7e581a5 --- /dev/null +++ b/packages/cache/src/middleware.ts @@ -0,0 +1,196 @@ +import type { Context, Middleware, MiddlewareOptions } from '@orpc/server' +import type { Promisable, Value } from '@orpc/shared' +import type { CacheHandlerPluginContext } from './handler-plugin' +import type { CacheContext } from './types' +import { RPCJsonSerializer } from '@orpc/client' +import { isAsyncIteratorObject, stringifyJSON, toArray, value } from '@orpc/shared' +import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' + +/** + * A cache key, or any serializable value to derive one from. + * Kept as a wide union instead of `unknown` so callback parameters + * stay contextually typed. + */ +export type CacheKeyMaterial = string | number | bigint | boolean | object | null | undefined + +export interface CacheMiddlewareOptions< + TInContext extends Context, + TInput, +> { + /** + * The key identifying the cache entry, or any serializable value to derive + * it from. Strings are used verbatim, while any other value is combined + * with the procedure path and encoded into a key. + * + * @default the procedure path and input + */ + key?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * Tags associated with the entry. Revalidating any of them invalidates the entry. + * + * @default [] + */ + tags?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * Fresh lifetime in milliseconds. `undefined` means the entry never expires by time. + * + * @default undefined + */ + ttl?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * Extra stale-while-revalidate window in milliseconds after `ttl`. + * Stale entries are served immediately while the procedure re-executes in the background. + * + * @default 0 + */ + swr?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * When resolved to `false`, skips both the cache lookup and the store for this request. + * + * @default true + */ + enabled?: Value, [options: MiddlewareOptions>, input: TInput]> +} + +/** + * Creates a middleware that caches procedure output in the `context.cache` store, + * with tag-based revalidation and optional stale-while-revalidate. + * By default the key is derived from the procedure path and input. + * Streaming outputs (event iterators, readable streams) are never cached. + * + * @see {@link https://orpc.dev/docs/helpers/cache#cache-middleware | Cache Helpers - Cache Middleware} + */ +export function cache< + TInContext extends Context, + TInput, +>( + options: CacheMiddlewareOptions = {}, +): Middleware { + return async function cache(middlewareOptions, input, done) { + const [keyMaterial, tags = [], ttl, swr, enabled = true] = await Promise.all([ + options.key !== undefined ? value(options.key, middlewareOptions, input) : input, + value(options.tags, middlewareOptions, input), + value(options.ttl, middlewareOptions, input), + value(options.swr, middlewareOptions, input), + value(options.enabled, middlewareOptions, input), + ]) + + if (!enabled) { + return middlewareOptions.next() + } + + const key = typeof keyMaterial === 'string' ? keyMaterial : encodeCacheKey(middlewareOptions.path, keyMaterial) + + const { cache: store, waitUntil } = middlewareOptions.context as CacheContext + const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + + const entry = await store.get(key) + + if (entry) { + const stale = entry.expiresAt !== undefined && Date.now() >= entry.expiresAt + + if (stale) { + const refresh = Promise.resolve(middlewareOptions.next()) + .then(async (result) => { + if (!isUncacheableOutput(result.output)) { + await store.set(key, result.output, { tags, ttl, swr }) + } + }) + .catch(() => { + // A background refresh failure cannot affect the already-served + // response; the next stale hit retries. + }) + + waitUntil?.(refresh) + } + + pluginContext?.caches.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + hit: true, + stale, + key, + tags: entry.tags, + // The entry's remaining freshness, so reflected HTTP caching headers never + // outlive the store entry. + ttl: entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - Date.now()) : undefined, + swr, + }) + + return done({ output: entry.output }) + } + + const result = await middlewareOptions.next() + + if (isUncacheableOutput(result.output)) { + return result + } + + await store.set(key, result.output, { tags, ttl, swr }) + + pluginContext?.caches.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + hit: false, + stale: false, + key, + tags, + ttl, + swr, + }) + + return result + } +} + +/** + * Creates a middleware that revalidates cache tags in the `context.cache` store + * after the procedure succeeds, typically on mutations. Errors skip the revalidation entirely. + * + * @see {@link https://orpc.dev/docs/helpers/cache#revalidate-middleware | Cache Helpers - Revalidate Middleware} + */ +export function revalidate< + TInContext extends Context, + TInput, +>( + tags: Value, [options: MiddlewareOptions>, input: TInput]>, +): Middleware { + return async function revalidate(middlewareOptions, input) { + const result = await middlewareOptions.next() + + const resolvedTags = toArray(await value(tags, middlewareOptions, input)) + + if (resolvedTags.length) { + await (middlewareOptions.context as CacheContext).cache.revalidateTag(resolvedTags as [string, ...string[]]) + + const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + pluginContext?.revalidations.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + tags: resolvedTags, + }) + } + + return result + } +} + +function isUncacheableOutput(output: unknown): boolean { + return isAsyncIteratorObject(output) || output instanceof ReadableStream +} + +const cacheKeySerializer = new RPCJsonSerializer() + +function encodeCacheKey(path: readonly string[], material: unknown): string { + const { json, meta, blobs } = cacheKeySerializer.serialize(material) + + if (blobs?.length) { + throw new TypeError('Cache key material must not contain Blob or File values; provide an explicit string key instead') + } + + return stringifyJSON({ path, json, meta }) +} diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts new file mode 100644 index 000000000..ff75cfb96 --- /dev/null +++ b/packages/cache/src/types.ts @@ -0,0 +1,96 @@ +/** + * A cached procedure output alongside its metadata. + * + * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} + */ +export interface CacheEntry { + /** + * The cached procedure output. + */ + output: unknown + + /** + * The tags recorded when the entry was stored. + */ + tags: readonly string[] + + /** + * The time (unix timestamp in milliseconds) when the entry stops being fresh. + * `undefined` means the entry never becomes stale. + */ + expiresAt?: number | undefined +} + +/** + * Options accepted by {@link CacheStore.set}. + * + * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} + */ +export interface CacheSetOptions { + /** + * Tags associated with the entry. Revalidating any of them invalidates the entry. + * + * @default [] + */ + tags?: readonly string[] + + /** + * Fresh lifetime in milliseconds. `undefined` means the entry never expires by time. + * + * @default undefined + */ + ttl?: number + + /** + * Extra stale-while-revalidate window in milliseconds after `ttl`. + * During this window the store still returns the entry with a past `expiresAt`. + * Ignored when `ttl` is `undefined`. + * + * @default 0 + */ + swr?: number +} + +/** + * Storage contract used by the cache middleware. Implementations own + * expiry and tag tracking: `set` records tags, `revalidateTag` invalidates + * every entry associated with them. + * + * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} + */ +export interface CacheStore { + /** + * Resolves the entry stored under `key`, or `undefined` on miss/evicted/revalidated. + * Stale entries (past `expiresAt` but within the stale-while-revalidate window) are returned. + */ + get(key: string): Promise + + /** + * Stores `output` under `key`, replacing any previous entry. + */ + set(key: string, output: unknown, options?: CacheSetOptions): Promise + + /** + * Invalidates every entry associated with one or many tags. + */ + revalidateTag(tag: string | readonly [string, ...string[]]): Promise +} + +/** + * The context required by the cache and revalidate middlewares. + * + * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} + */ +export interface CacheContext { + /** + * The cache store shared by every cached procedure behind one handler. + */ + cache: CacheStore + + /** + * Extends the request lifetime for background work such as + * stale-while-revalidate refreshes. Required on runtimes that kill pending + * work once the response is sent, like Cloudflare Workers (`ctx.waitUntil`). + */ + waitUntil?: (promise: Promise) => void +} diff --git a/packages/cache/tests/e2e.test.ts b/packages/cache/tests/e2e.test.ts new file mode 100644 index 000000000..da13d8548 --- /dev/null +++ b/packages/cache/tests/e2e.test.ts @@ -0,0 +1,77 @@ +import type { CacheContext } from '../src' +import { os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import { z } from 'zod' +import { cache, CacheHandlerPlugin, revalidate } from '../src' +import { MemoryCacheStore } from '../src/adapters/memory' + +it('works', async () => { + const findHandlerFn = vi.fn(({ input }) => ({ id: input.id, name: `Planet ${input.id}` })) + + const router = { + planet: { + find: os + .$context() + .input(z.object({ id: z.number() })) + .use( + cache({ + key: (_, input) => `planet:${input.id}`, + tags: (_, input) => ['planets', `planet:${input.id}`], + }), + ) + .handler(findHandlerFn), + update: os + .$context() + .input(z.object({ id: z.number(), name: z.string() })) + .use( + revalidate((_, input) => ['planets', `planet:${input.id}`]), + ) + .handler(({ input }) => input), + }, + } + + const handler = new RPCHandler(router, { + plugins: [ + new CacheHandlerPlugin(), + ], + }) + + const store = new MemoryCacheStore() + + const request = (path: string, body: unknown) => new Request(`https://example.com/${path}`, { + method: 'POST', + body: JSON.stringify({ json: body }), + headers: { + 'Content-Type': 'application/json', + }, + }) + + const find = () => handler.handle(request('planet/find', { id: 1 }), { + context: { cache: store }, + }) + + // miss: the handler runs and the response carries the cache tags + const first = await find() + expect(first.response?.status).toBe(200) + expect(first.response?.headers.get('orpc-cache-tag')).toBe('planets,planet:1') + expect(findHandlerFn).toHaveBeenCalledTimes(1) + + // hit: the handler does not re-run and the response body is identical + const second = await find() + expect(second.response?.status).toBe(200) + expect(second.response?.headers.get('orpc-cache-tag')).toBe('planets,planet:1') + expect(findHandlerFn).toHaveBeenCalledTimes(1) + await expect(second.response?.json()).resolves.toEqual(await first.response?.clone().json()) + + // update: revalidates the tags and reflects them in the invalidation header + const update = await handler.handle(request('planet/update', { id: 1, name: 'Mars' }), { + context: { cache: store }, + }) + expect(update.response?.status).toBe(200) + expect(update.response?.headers.get('orpc-cache-tag-invalidation')).toBe('planets,planet:1') + + // miss again: the revalidation evicted the entry + const third = await find() + expect(third.response?.status).toBe(200) + expect(findHandlerFn).toHaveBeenCalledTimes(2) +}) diff --git a/packages/cache/tsconfig.json b/packages/cache/tsconfig.json new file mode 100644 index 000000000..211b35f0c --- /dev/null +++ b/packages/cache/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../client" }, + { "path": "../server" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.bench.*", + "**/*.test.*", + "**/*.test-d.ts", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/client/README.md b/packages/client/README.md index c958af360..12ed79373 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index e89f901d7..dd96494f7 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 9fd13eebd..fd6c39e11 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -40,6 +40,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", diff --git a/packages/cloudflare/src/cache-api.test.ts b/packages/cloudflare/src/cache-api.test.ts new file mode 100644 index 000000000..48a2d0539 --- /dev/null +++ b/packages/cloudflare/src/cache-api.test.ts @@ -0,0 +1,160 @@ +import type { CloudflareCacheApiCacheStoreOptions } from './cache-api' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CloudflareCacheApiCacheStore } from './cache-api' + +describe('cloudflareCacheApiCacheStore', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + function createTestingStore(options: CloudflareCacheApiCacheStoreOptions = {}) { + const baseUrl = `https://example.com/__orpc/cache/${crypto.randomUUID()}` + + const store = new CloudflareCacheApiCacheStore({ + baseUrl, + zoneId: 'zone-1', + apiToken: 'token-1', + ...options, + }) + + return { store, baseUrl } + } + + function stubPurgeApi(...responses: object[]) { + const fetchFn = vi.fn() + for (const response of responses) { + fetchFn.mockResolvedValueOnce(Response.json(response)) + } + vi.stubGlobal('fetch', fetchFn) + return fetchFn + } + + it('round-trips outputs with tags and expiresAt, including undefined', async () => { + const { store } = createTestingStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120_000 }) + + const entry = await store.get('k') + expect(entry!.output).toEqual({ nested: [1, 2] }) + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(Date.now()) + + await store.set('u', undefined) + await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: [], expiresAt: undefined }) + }) + + it('misses on unknown keys', async () => { + const { store } = createTestingStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + const { store } = createTestingStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + nothing: undefined, + } + + await store.set('k', output) + + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('rejects outputs containing blobs', async () => { + const { store } = createTestingStore() + + await expect( + store.set('k', { file: new Blob(['x']) }), + ).rejects.toThrow('CloudflareCacheApiCacheStore cannot cache outputs containing Blob or File values') + }) + + it('supports a custom serializer', async () => { + const serializer = { + stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), + parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), + } + const { store } = createTestingStore({ serializer }) + + await store.set('k', { a: 1 }) + + await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializer.stringify).toHaveBeenCalled() + expect(serializer.parse).toHaveBeenCalled() + }) + + it('stores entries with encoded Cache-Tag headers and second-based retention', async () => { + const { store, baseUrl } = createTestingStore() + + await store.set('k', 'v', { tags: ['a,b', 'planets'], ttl: 1000, swr: 500 }) + + const stored = await caches.default.match(`${baseUrl}/k`) + expect(stored!.headers.get('cache-tag')).toBe('a%2Cb,planets') + expect(stored!.headers.get('cache-control')).toBe('public, s-maxage=2') + + await store.set('forever', 'v') + + const foreverStored = await caches.default.match(`${baseUrl}/forever`) + expect(foreverStored!.headers.get('cache-tag')).toBe(null) + expect(foreverStored!.headers.get('cache-control')).toBe('public, s-maxage=31536000') + }) + + it('serves stale entries within the swr window, then evicts at the exact bound', async () => { + const { store, baseUrl } = createTestingStore() + + const envelope = (expiresAt: number, evictAt: number) => new Response( + JSON.stringify({ output: JSON.stringify({ json: 'v' }), tags: [], expiresAt, evictAt }), + { headers: { 'cache-control': 'public, s-maxage=3600' } }, + ) + + await caches.default.put(`${baseUrl}/stale`, envelope(Date.now() - 1000, Date.now() + 60_000)) + await caches.default.put(`${baseUrl}/evicted`, envelope(Date.now() - 2000, Date.now() - 1000)) + + const stale = await store.get('stale') + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(Date.now()) + + await expect(store.get('evicted')).resolves.toBeUndefined() + await expect(caches.default.match(`${baseUrl}/evicted`)).resolves.toBeUndefined() + }) + + it('purges tags zone-wide through the purge API', async () => { + const { store } = createTestingStore() + const fetchFn = stubPurgeApi({ success: true }) + + await store.revalidateTag(['planets', 'a,b']) + + expect(fetchFn).toHaveBeenCalledTimes(1) + expect(fetchFn).toHaveBeenCalledWith('https://api.cloudflare.com/client/v4/zones/zone-1/purge_cache', { + method: 'POST', + headers: { + 'authorization': 'Bearer token-1', + 'content-type': 'application/json', + }, + body: JSON.stringify({ tags: ['planets', 'a%2Cb'] }), + }) + }) + + it('splits large purges into batches of 100 tags', async () => { + const { store } = createTestingStore() + const fetchFn = stubPurgeApi({ success: true }, { success: true }) + + await store.revalidateTag(Array.from({ length: 150 }, (_, i) => `tag-${i}`) as [string, ...string[]]) + + expect(fetchFn).toHaveBeenCalledTimes(2) + expect(JSON.parse(fetchFn.mock.calls[0]![1]!.body as string).tags).toHaveLength(100) + expect(JSON.parse(fetchFn.mock.calls[1]![1]!.body as string).tags).toHaveLength(50) + }) + + it('throws when the purge API reports a failure', async () => { + const { store } = createTestingStore() + stubPurgeApi({ success: false, errors: [{ message: 'Invalid API token' }] }) + + await expect(store.revalidateTag('planets')).rejects.toThrow( + 'CloudflareCacheApiCacheStore failed to purge tags (status 200): Invalid API token', + ) + }) +}) diff --git a/packages/cloudflare/src/cache-api.ts b/packages/cloudflare/src/cache-api.ts new file mode 100644 index 000000000..cd65cf598 --- /dev/null +++ b/packages/cloudflare/src/cache-api.ts @@ -0,0 +1,170 @@ +import type { CacheEntry, CacheOutputSerializer, CacheSetOptions, CacheStore } from '@orpc/cache' +import { createRpcJsonOutputSerializer, encodeCacheTagHeader } from '@orpc/cache' +import { stringifyJSON, toArray } from '@orpc/shared' + +/** + * The purge API accepts a limited number of tags per call, so larger + * revalidations are split into batches of this size. + */ +const PURGE_TAGS_BATCH_SIZE = 100 + +interface CloudflareCacheApiCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: string + tags: readonly string[] + expiresAt?: number | undefined + evictAt?: number | undefined +} + +export interface CloudflareCacheApiCacheStoreOptions { + /** + * The Cache API instance to store entries in. + * + * @default caches.default + */ + cache?: Cache + + /** + * Serializer for cached outputs. + * + * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + */ + serializer?: CacheOutputSerializer +} + +/** + * Cache store adapter for the Cloudflare Cache API with tag purging through + * Cloudflare's purge API. Entries are stored per data center under synthetic + * URLs below `baseUrl` and carry their tags in a `Cache-Tag` header; + * revalidations purge those tags zone-wide via Instant Purge. + * + * @remarks + * **Note**: `baseUrl` must live under the purged zone, the API token needs + * the Zone > Cache Purge permission, and purge calls are subject to your + * plan's purge rate limits. The Cache API is inert in dashboard previews and + * each data center caches entries independently. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class CloudflareCacheApiCacheStore implements CacheStore { + private readonly baseUrl: string + private readonly zoneId: string + private readonly apiToken: string + private readonly cache: Cache | undefined + private readonly serializer: CacheOutputSerializer + + constructor( + options: { + /** + * An URL under your zone used to derive the synthetic cache keys, + * e.g. `https://example.com/__orpc/cache`. + */ + baseUrl: string + + /** + * The zone ID used for tag purges. + */ + zoneId: string + + /** + * An API token with the Zone > Cache Purge permission. + */ + apiToken: string + } & CloudflareCacheApiCacheStoreOptions, + ) { + this.baseUrl = options.baseUrl.replace(/\/$/, '') + this.zoneId = options.zoneId + this.apiToken = options.apiToken + this.cache = options.cache + this.serializer = options.serializer ?? createRpcJsonOutputSerializer('CloudflareCacheApiCacheStore') + } + + async get(key: string): Promise { + const response = await this.resolveCache().match(this.entryUrl(key)) + + if (!response) { + return undefined + } + + const envelope = await response.json() as CloudflareCacheApiCacheStoreEnvelope + + if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { + await this.resolveCache().delete(this.entryUrl(key)) + return undefined + } + + return { + output: this.serializer.parse(envelope.output), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const tags = options?.tags ?? [] + const serialized = this.serializer.stringify(output) + + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const evictAt = retention !== undefined ? Date.now() + retention : undefined + + const envelope: CloudflareCacheApiCacheStoreEnvelope = { + output: serialized, + tags, + expiresAt, + evictAt, + } + + const headers: Record = { + /** + * The Cache API is ephemeral, so entries without a ttl are retained + * for a year and evicted earlier under storage pressure. + */ + 'cache-control': `public, s-maxage=${retention !== undefined ? Math.ceil(retention / 1000) : 31536000}`, + } + + if (tags.length) { + headers['cache-tag'] = encodeCacheTagHeader(tags) + } + + await this.resolveCache().put(this.entryUrl(key), new Response(stringifyJSON(envelope), { headers })) + } + + async revalidateTag(tag: string | readonly string[]): Promise { + const tags = toArray(tag) + + for (let i = 0; i < tags.length; i += PURGE_TAGS_BATCH_SIZE) { + const batch = tags.slice(i, i + PURGE_TAGS_BATCH_SIZE) + + const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`, { + method: 'POST', + headers: { + 'authorization': `Bearer ${this.apiToken}`, + 'content-type': 'application/json', + }, + body: stringifyJSON({ + // Tags must match the stored Cache-Tag header, so each one is encoded the same way. + tags: batch.map(t => encodeCacheTagHeader([t])), + }), + }) + + const result = await response.json() as { success?: boolean, errors?: { message?: string }[] } + + if (!response.ok || !result.success) { + const messages = toArray(result.errors).map(error => error.message).filter(Boolean).join('; ') + throw new Error(`CloudflareCacheApiCacheStore failed to purge tags (status ${response.status})${messages ? `: ${messages}` : ''}`) + } + } + } + + private resolveCache(): Cache { + // Cast because Cloudflare's `caches.default` is not part of the standard CacheStorage typings. + return this.cache ?? (caches as unknown as { default: Cache }).default + } + + private entryUrl(key: string): string { + return `${this.baseUrl}/${encodeURIComponent(key)}` + } +} diff --git a/packages/cloudflare/src/cache.test.ts b/packages/cloudflare/src/cache.test.ts new file mode 100644 index 000000000..cc961e7f4 --- /dev/null +++ b/packages/cloudflare/src/cache.test.ts @@ -0,0 +1,135 @@ +import type { CloudflareKVCacheStoreOptions } from './cache' +import { env } from 'cloudflare:workers' +import { describe, expect, it, vi } from 'vitest' +import { CloudflareKVCacheStore } from './cache' + +describe('cloudflareKVCacheStore', () => { + function createTestingStore(options: CloudflareKVCacheStoreOptions = {}) { + const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` + return { store: new CloudflareKVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } + } + + it('round-trips outputs with tags and expiresAt, including undefined', async () => { + const { store } = createTestingStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120_000 }) + + const entry = await store.get('k') + expect(entry!.output).toEqual({ nested: [1, 2] }) + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(Date.now()) + + await store.set('u', undefined) + await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: [], expiresAt: undefined }) + }) + + it('misses on unknown keys', async () => { + const { store } = createTestingStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + const { store } = createTestingStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + nothing: undefined, + } + + await store.set('k', output) + + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('rejects outputs containing blobs', async () => { + const { store } = createTestingStore() + + await expect( + store.set('k', { file: new Blob(['x']) }), + ).rejects.toThrow('CloudflareKVCacheStore cannot cache outputs containing Blob or File values') + }) + + it('supports a custom serializer', async () => { + const serializer = { + stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), + parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), + } + const { store } = createTestingStore({ serializer }) + + await store.set('k', { a: 1 }) + + await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializer.stringify).toHaveBeenCalled() + expect(serializer.parse).toHaveBeenCalled() + }) + + it('invalidates entries by any of their tags', async () => { + const { store } = createTestingStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidateTag('a') + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + }) + + it('revalidates many tags at once', async () => { + const { store } = createTestingStore() + + await store.set('a', 'v', { tags: ['a'] }) + await store.set('b', 'v', { tags: ['b'] }) + + await store.revalidateTag(['a', 'b']) + + await expect(store.get('a')).resolves.toBeUndefined() + await expect(store.get('b')).resolves.toBeUndefined() + }) + + it('entries set after a revalidation remain valid', async () => { + const { store } = createTestingStore() + + await store.set('k', 'old', { tags: ['t'] }) + await store.revalidateTag('t') + await store.set('k', 'new', { tags: ['t'] }) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'new' }) + }) + + it('serves stale entries within the swr window, then evicts at the exact bound', async () => { + const { store, prefix } = createTestingStore() + + // Craft envelopes directly so the test does not have to wait for real time to pass. + const envelope = (expiresAt: number, evictAt: number) => JSON.stringify({ + output: JSON.stringify({ json: 'v' }), + tags: [], + tagTokens: {}, + expiresAt, + evictAt, + }) + + await env.CACHE_KV.put(`${prefix}entry:stale`, envelope(Date.now() - 1000, Date.now() + 60_000)) + await env.CACHE_KV.put(`${prefix}entry:evicted`, envelope(Date.now() - 2000, Date.now() - 1000)) + + const stale = await store.get('stale') + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(Date.now()) + + await expect(store.get('evicted')).resolves.toBeUndefined() + await expect(env.CACHE_KV.get(`${prefix}entry:evicted`)).resolves.toBeNull() + }) + + it('stores entries and tag tokens under the prefixed key families', async () => { + const { store, prefix } = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidateTag('t') + + await expect(env.CACHE_KV.get(`${prefix}entry:k`)).resolves.toBeTypeOf('string') + await expect(env.CACHE_KV.get(`${prefix}tag:t`)).resolves.toBeTypeOf('string') + }) +}) diff --git a/packages/cloudflare/src/cache.ts b/packages/cloudflare/src/cache.ts new file mode 100644 index 000000000..27e6fe705 --- /dev/null +++ b/packages/cloudflare/src/cache.ts @@ -0,0 +1,146 @@ +import type { CacheEntry, CacheOutputSerializer, CacheSetOptions, CacheStore } from '@orpc/cache' +import { createRpcJsonOutputSerializer } from '@orpc/cache' +import { stringifyJSON, toArray } from '@orpc/shared' + +interface CloudflareKVCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: string + tags: readonly string[] + /** + * Tag tokens snapshotted at set time. A tag's live token changes on every + * revalidation, so a mismatch (or a token appearing/disappearing) means + * the entry is invalid. + */ + tagTokens: Record + expiresAt?: number | undefined + evictAt?: number | undefined +} + +export interface CloudflareKVCacheStoreOptions { + /** + * The prefix to use for KV keys. + * + * @default 'orpc:cache:' + */ + prefix?: string + + /** + * Serializer for cached outputs. + * + * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + */ + serializer?: CacheOutputSerializer +} + +/** + * Cache store adapter for Cloudflare Workers KV with tag-based invalidation. + * Tags are tracked with random tokens rewritten on every revalidation, so no + * atomic operations are required. Entries are retained for `ttl + swr` via + * `expirationTtl`, clamped to KV's 60 second minimum; the exact bounds are + * still enforced on `get`. + * + * @remarks + * **Note**: KV is [eventually consistent](https://developers.cloudflare.com/kv/concepts/how-kv-works/#consistency): + * writes and revalidations may take 60 seconds or more to be visible in other + * locations, so recently invalidated entries can still be served there. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class CloudflareKVCacheStore implements CacheStore { + private readonly kv: KVNamespace + private readonly prefix: string + private readonly serializer: CacheOutputSerializer + + constructor( + kv: KVNamespace, + options: CloudflareKVCacheStoreOptions = {}, + ) { + this.kv = kv + this.prefix = options.prefix ?? 'orpc:cache:' + this.serializer = options.serializer ?? createRpcJsonOutputSerializer('CloudflareKVCacheStore') + } + + async get(key: string): Promise { + const envelope = await this.kv.get(this.entryKey(key), 'json') + + if (envelope === null) { + return undefined + } + + if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { + await this.kv.delete(this.entryKey(key)) + return undefined + } + + if (envelope.tags.length) { + const tokens = await Promise.all(envelope.tags.map(tag => this.kv.get(this.tagKey(tag)))) + + const revalidated = envelope.tags.some( + (tag, index) => tokens[index] !== (envelope.tagTokens[tag] ?? null), + ) + + if (revalidated) { + await this.kv.delete(this.entryKey(key)) + return undefined + } + } + + return { + output: this.serializer.parse(envelope.output), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const tags = options?.tags ?? [] + const serialized = this.serializer.stringify(output) + + const tagTokens: Record = {} + if (tags.length) { + const tokens = await Promise.all(tags.map(tag => this.kv.get(this.tagKey(tag)))) + tags.forEach((tag, index) => { + tagTokens[tag] = tokens[index] ?? null + }) + } + + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const evictAt = retention !== undefined ? Date.now() + retention : undefined + + const envelope: CloudflareKVCacheStoreEnvelope = { + output: serialized, + tags, + tagTokens, + expiresAt, + evictAt, + } + + await this.kv.put( + this.entryKey(key), + stringifyJSON(envelope), + // KV rejects expirations under 60 seconds; evictAt still enforces the exact bound on get. + retention !== undefined ? { expirationTtl: Math.max(60, Math.ceil(retention / 1000)) } : {}, + ) + } + + async revalidateTag(tag: string | readonly string[]): Promise { + const tags = toArray(tag) + + if (!tags.length) { + return + } + + await Promise.all(tags.map(t => this.kv.put(this.tagKey(t), crypto.randomUUID()))) + } + + private entryKey(key: string): string { + return `${this.prefix}entry:${key}` + } + + private tagKey(tag: string): string { + return `${this.prefix}tag:${tag}` + } +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index c97259472..4669008a2 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,3 +1,6 @@ +export * from './cache' +export * from './cache-api' export * from './publisher' export * from './publisher-object' export * from './ratelimit' +export * from './workers-cache' diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts new file mode 100644 index 000000000..54c7869de --- /dev/null +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest' +import { CloudflareWorkersCacheStore } from './workers-cache' + +describe('cloudflareWorkersCacheStore', () => { + const createPurger = () => ({ + purge: vi.fn(async () => ({ success: true })), + }) + + it('always misses and stores nothing', async () => { + const purger = createPurger() + const store = new CloudflareWorkersCacheStore(purger) + + await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) + await expect(store.get('k')).resolves.toBeUndefined() + expect(purger.purge).not.toHaveBeenCalled() + }) + + it('purges encoded tags through workers caching', async () => { + const purger = createPurger() + const store = new CloudflareWorkersCacheStore(purger) + + await store.revalidateTag(['planets', 'a,b']) + + expect(purger.purge).toHaveBeenCalledTimes(1) + expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets', 'a%2Cb'] }) + }) + + it('accepts a single tag', async () => { + const purger = createPurger() + const store = new CloudflareWorkersCacheStore(purger) + + await store.revalidateTag('planets') + + expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets'] }) + }) + + it('throws when the purge fails, including error messages', async () => { + const purger = { + purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), + } + const store = new CloudflareWorkersCacheStore(purger) + + await expect(store.revalidateTag('planets')).rejects.toThrow( + 'CloudflareWorkersCacheStore failed to purge tags: Rate limited', + ) + }) +}) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts new file mode 100644 index 000000000..db48f2165 --- /dev/null +++ b/packages/cloudflare/src/workers-cache.ts @@ -0,0 +1,59 @@ +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' +import { encodeCacheTagHeader } from '@orpc/cache' +import { toArray } from '@orpc/shared' + +/** + * The purge surface of Cloudflare Workers Caching, satisfied by both + * `ctx.cache` and `cache` imported from `cloudflare:workers`. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export interface CloudflareWorkersCachePurger { + purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> +} + +/** + * Purge-only cache store for Cloudflare Workers Caching. Responses are cached + * in front of the Worker through `Cache-Control` and `Cache-Tag` headers (see + * the `CacheHandlerPlugin` `httpCacheHeaders` option), so `get` always misses + * and `set` stores nothing; `revalidateTag` purges the tags through Workers + * Caching. + * + * @remarks + * **Note**: Purges are scoped to the calling entrypoint, tags are matched + * case-insensitively, and purge calls always use the Free tier rate limits + * regardless of your plan. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class CloudflareWorkersCacheStore implements CacheStore { + constructor( + private readonly cache: CloudflareWorkersCachePurger, + ) {} + + async get(_key: string): Promise { + return undefined + } + + async set(_key: string, _output: unknown, _options?: CacheSetOptions): Promise { + // Storage happens at the response layer, driven by the reflected headers. + } + + async revalidateTag(tag: string | readonly string[]): Promise { + const tags = toArray(tag) + + if (!tags.length) { + return + } + + const result = await this.cache.purge({ + // Tags must match the reflected Cache-Tag header, so each one is encoded the same way. + tags: tags.map(t => encodeCacheTagHeader([t])), + }) + + if (!result.success) { + const messages = toArray(result.errors).map(error => error.message).filter(Boolean).join('; ') + throw new Error(`CloudflareWorkersCacheStore failed to purge tags${messages ? `: ${messages}` : ''}`) + } + } +} diff --git a/packages/cloudflare/wrangler.jsonc b/packages/cloudflare/wrangler.jsonc index a3196f350..f1a82d2cb 100644 --- a/packages/cloudflare/wrangler.jsonc +++ b/packages/cloudflare/wrangler.jsonc @@ -2,6 +2,12 @@ "$schema": "./node_modules/wrangler/config-schema.json", "compatibility_date": "2026-07-01", "main": "./tests/__shared__/main.ts", + "kv_namespaces": [ + { + "binding": "CACHE_KV", + "id": "cache-kv-test" + } + ], "ratelimits": [ { "name": "RATELIMIT_3_10S", diff --git a/packages/contract/README.md b/packages/contract/README.md index 2aecf7f85..8eb5dd5c2 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/effect/README.md b/packages/effect/README.md index 42dd055ce..de60eb91a 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index f89e32c47..ffb6fe58f 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 32272560a..510f418df 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 7cc87a288..dc9c781b7 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/nest/README.md b/packages/nest/README.md index 80c412c9d..35d721d0d 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/next/README.md b/packages/next/README.md index b1597d28a..070b18c68 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/node/README.md b/packages/node/README.md index 46c1a041e..90a597490 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index b9f110935..31facd88b 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 5945e6f34..08764deb8 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 6f81d5963..308ee21df 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pino/README.md b/packages/pino/README.md index 6743f1d2d..5cc729e64 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index dbac2b921..271e5fd5e 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 006669cd5..49d70a9c3 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/server/README.md b/packages/server/README.md index 659d8d50e..257c09e07 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/server/src/procedure-client.test.ts b/packages/server/src/procedure-client.test.ts index 325ea1187..22ffb77d9 100644 --- a/packages/server/src/procedure-client.test.ts +++ b/packages/server/src/procedure-client.test.ts @@ -369,7 +369,7 @@ describe('createProcedureClient', () => { expect(mid3).toHaveBeenCalledTimes(1) expect(mid3).toHaveBeenCalledAfter(mid2) expect(mid3).toHaveBeenCalledWith( - expect.objectContaining({}), + expect.objectContaining({ }), 'inputSchema3__inputSchema2__inputSchema1__INPUT', expect.any(Function), ) @@ -622,9 +622,11 @@ describe('createProcedureClient', () => { .resolves .toEqual({ parent: 'parent__PARENT', child: 'child__CHILD' }) + // Middlewares receive the full input with the validated fragments merged over + // it, instead of only the parts validated up to their position. expect(rootMid).toHaveBeenCalledWith(expect.any(Object), { parent: 'PARENT', child: 'CHILD', unknown: 'UNKNOWN' }, expect.any(Function)) - expect(parentMid).toHaveBeenCalledWith(expect.any(Object), { parent: 'parent__PARENT' }, expect.any(Function)) - expect(childMid).toHaveBeenCalledWith(expect.any(Object), { parent: 'parent__PARENT', child: 'child__CHILD' }, expect.any(Function)) + expect(parentMid).toHaveBeenCalledWith(expect.any(Object), { parent: 'parent__PARENT', child: 'CHILD', unknown: 'UNKNOWN' }, expect.any(Function)) + expect(childMid).toHaveBeenCalledWith(expect.any(Object), { parent: 'parent__PARENT', child: 'child__CHILD', unknown: 'UNKNOWN' }, expect.any(Function)) }) it('composes fragments nested one level deep', async () => { diff --git a/packages/server/src/procedure-client.ts b/packages/server/src/procedure-client.ts index 251533189..a999551c7 100644 --- a/packages/server/src/procedure-client.ts +++ b/packages/server/src/procedure-client.ts @@ -242,6 +242,15 @@ async function executeProcedureInternal(procedure: AnyProcedure, options: Proced if (midIndex < orderedMiddlewares.length) { const { middleware } = orderedMiddlewares[midIndex]! + /** + * With stacked object schemas, the validated fragments only cover the schemas + * declared before this middleware, so they are merged over the original input + * to give middlewares the full input instead of only the validated parts. + */ + const middlewareInput = inputSchemas.length > 1 && isPlainObject(currentInput) + ? mergeTwoLevels(options.input, currentInput) + : currentInput + const result = await runWithSpan(`middleware.${middleware.name}`, async (span) => { span?.setAttribute('middleware.index', midIndex) @@ -262,7 +271,7 @@ async function executeProcedureInternal(procedure: AnyProcedure, options: Proced }, lastEventId: options.lastEventId, }, - currentInput, + middlewareInput, middlewareDone, ) }) diff --git a/packages/shared/README.md b/packages/shared/README.md index d623b02d9..8a9c2f2ca 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/swr/README.md b/packages/swr/README.md index ee11ba541..c0a459478 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 3649efcb8..7608431c0 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index e72bd99fe..c15abb981 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index c8a447987..27b233009 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/zod/README.md b/packages/zod/README.md index 485a05fd2..09992c86c 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://npmx.dev/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://npmx.dev/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/cache](https://npmx.dev/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://npmx.dev/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://npmx.dev/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -58,7 +59,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://npmx.dev/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://npmx.dev/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://npmx.dev/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://npmx.dev/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://npmx.dev/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57e349ea9..e8959a7b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@orpc/arktype': specifier: workspace:* version: link:packages/arktype + '@orpc/cache': + specifier: workspace:* + version: link:packages/cache '@orpc/client': specifier: workspace:* version: link:packages/client @@ -194,6 +197,9 @@ importers: '@orpc/arktype': specifier: workspace:* version: link:../../packages/arktype + '@orpc/cache': + specifier: workspace:* + version: link:../../packages/cache '@orpc/client': specifier: workspace:* version: link:../../packages/client @@ -247,7 +253,7 @@ importers: version: 26.2.0 blume: specifier: ^1.4.3 - version: 1.4.3(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@shikijs/themes@4.4.3)(@types/node@26.2.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(csstype@3.2.3)(esbuild@0.28.2)(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(prettier@3.9.6)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(terser@5.50.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0) + version: 1.4.3(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@shikijs/themes@4.4.3)(@types/node@26.2.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(csstype@3.2.3)(esbuild@0.28.2)(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(prettier@3.9.6)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(terser@5.50.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0) effect: specifier: 4.0.0-rc.109 version: 4.0.0-rc.109 @@ -330,6 +336,28 @@ importers: specifier: ^6.2.1 version: 6.2.1(@opentelemetry/api@1.9.1) + packages/cache: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@standardserver/core': + specifier: ^0.8.0 + version: 0.8.0 + devDependencies: + '@vercel/functions': + specifier: ^3.9.5 + version: 3.9.5(ws@8.21.3) + redis: + specifier: ^6.2.1 + version: 6.2.1(@opentelemetry/api@1.9.1) + packages/client: dependencies: '@orpc/shared': @@ -351,6 +379,9 @@ importers: packages/cloudflare: dependencies: + '@orpc/cache': + specifier: workspace:* + version: link:../cache '@orpc/client': specifier: workspace:* version: link:../client @@ -974,7 +1005,7 @@ importers: version: 5.101.4(react@19.2.8) '@types/bun': specifier: latest - version: 1.3.14 + version: 1.4.0 '@types/react': specifier: ^19.2.18 version: 19.2.18 @@ -5162,6 +5193,9 @@ packages: '@types/bun@1.3.14': resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/bun@1.4.0': + resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==} + '@types/bunyan@1.8.11': resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} @@ -5560,6 +5594,9 @@ packages: '@vercel/cli-config@0.2.3': resolution: {integrity: sha512-Ggh0Wmi92TUkUexmSUPkkDtvJmbjUr7IvF5T3FkSsWrXXs3GFzOujfxFpECdJZpux1JG4SWDv9BT4w++TDgD6A==} + '@vercel/cli-config@0.2.4': + resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==} + '@vercel/cli-exec@1.0.1': resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==} engines: {node: '>= 18'} @@ -5576,6 +5613,18 @@ packages: ws: optional: true + '@vercel/functions@3.9.5': + resolution: {integrity: sha512-EUfqlb7AzoEh7URlMNAO4jbJiLWz9grDBHvfjKTDvEP9c8y3DqX3SWPvfaQkUjtkm3b83flhaUUMuewdHa+qmw==} + engines: {node: '>= 20'} + peerDependencies: + '@aws-sdk/credential-provider-web-identity': '*' + ws: '>=8' + peerDependenciesMeta: + '@aws-sdk/credential-provider-web-identity': + optional: true + ws: + optional: true + '@vercel/nft@1.10.2': resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==} engines: {node: '>=20'} @@ -5593,6 +5642,10 @@ packages: resolution: {integrity: sha512-FGNvVZ5pgX9FaBqkPt6VkYFZ6bWAMDzYi7nxW+1Xt+Z4fn5PuTULVwsxjKc+0uKhysyWBQmvsmM50Oh6C2/oMA==} engines: {node: '>= 20'} + '@vercel/oidc@3.8.5': + resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==} + engines: {node: '>= 20'} + '@vercel/otel@2.1.3': resolution: {integrity: sha512-Ofvzs9qhftRD1YMLuPnhbXjQZG6IKrJ9AmEKmRHRGfoWlV89ed2gAOkvddkFiZDFZJm2rrFNdeZKRVxoCdnWiw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6280,6 +6333,9 @@ packages: bun-types@1.3.14: resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bun-types@1.4.0: + resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -12031,13 +12087,13 @@ snapshots: hast-util-from-html: 2.0.3 satteri: 0.9.5 - '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2)': + '@astrojs/mdx@7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2)': dependencies: '@astrojs/internal-helpers': 0.10.2 '@astrojs/markdown-remark': 7.2.2(supports-color@10.2.2) '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) acorn: 8.18.0 - astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) es-module-lexer: 2.3.1 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -12053,10 +12109,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@11.1.1(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2)': + '@astrojs/node@11.1.1(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2)': dependencies: '@astrojs/internal-helpers': 0.10.2 - astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) send: 1.2.1(supports-color@10.2.2) server-destroy: 1.0.1 transitivePeerDependencies: @@ -12099,14 +12155,14 @@ snapshots: is-docker: 4.0.0 package-manager-detector: 1.8.0 - '@astrojs/vercel@11.0.5(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)': + '@astrojs/vercel@11.0.5(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)': dependencies: '@astrojs/internal-helpers': 0.10.2 '@vercel/analytics': 1.6.1(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3)) '@vercel/functions': 3.9.3(ws@8.21.3) '@vercel/nft': 1.10.2(rollup@4.62.4)(supports-color@10.2.2) '@vercel/routing-utils': 5.3.3 - astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) esbuild: 0.28.2 tinyglobby: 0.2.17 transitivePeerDependencies: @@ -14983,10 +15039,10 @@ snapshots: - universal-cookie - zod - '@scalar/astro@0.4.14(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': + '@scalar/astro@0.4.14(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))': dependencies: '@scalar/client-side-rendering': 0.3.7 - astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) '@scalar/asyncapi-upgrader@0.1.5': dependencies: @@ -16059,6 +16115,10 @@ snapshots: dependencies: bun-types: 1.3.14 + '@types/bun@1.4.0': + dependencies: + bun-types: 1.4.0 + '@types/bunyan@1.8.11': dependencies: '@types/node': 26.2.0 @@ -16496,6 +16556,11 @@ snapshots: xdg-app-paths: 5.5.1 zod: 4.1.11 + '@vercel/cli-config@0.2.4': + dependencies: + xdg-app-paths: 5.5.1 + zod: 4.1.11 + '@vercel/cli-exec@1.0.1': dependencies: execa: 5.1.1 @@ -16506,6 +16571,12 @@ snapshots: optionalDependencies: ws: 8.21.3 + '@vercel/functions@3.9.5(ws@8.21.3)': + dependencies: + '@vercel/oidc': 3.8.5 + optionalDependencies: + ws: 8.21.3 + '@vercel/nft@1.10.2(rollup@4.62.4)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) @@ -16535,6 +16606,12 @@ snapshots: '@vercel/cli-exec': 1.0.1 jose: 5.10.0 + '@vercel/oidc@3.8.5': + dependencies: + '@vercel/cli-config': 0.2.4 + '@vercel/cli-exec': 1.0.1 + jose: 5.10.0 + '@vercel/otel@2.1.3(@opentelemetry/api-logs@0.221.0)(@opentelemetry/api@1.9.1)(@opentelemetry/instrumentation@0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2))(@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-logs@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-metrics@2.10.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))': dependencies: '@opentelemetry/api': 1.9.1 @@ -17096,7 +17173,7 @@ snapshots: astring@1.9.0: {} - astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0): + astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.3.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.10.2 @@ -17144,7 +17221,7 @@ snapshots: tinyglobby: 0.2.17 ultrahtml: 1.7.0 unifont: 0.7.5 - unstorage: 1.17.5(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3)) + unstorage: 1.17.5(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3)) vite: 8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) vitefu: 1.1.3(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) xxhash-wasm: 1.1.0 @@ -17265,14 +17342,14 @@ snapshots: blake3-wasm@2.1.5: {} - blume@1.4.3(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@shikijs/themes@4.4.3)(@types/node@26.2.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(csstype@3.2.3)(esbuild@0.28.2)(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(prettier@3.9.6)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(terser@5.50.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0): + blume@1.4.3(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@shikijs/themes@4.4.3)(@types/node@26.2.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(csstype@3.2.3)(esbuild@0.28.2)(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(prettier@3.9.6)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(terser@5.50.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0): dependencies: '@astrojs/check': 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.5 - '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2) - '@astrojs/node': 11.1.1(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2) + '@astrojs/mdx': 7.0.5(@astrojs/markdown-satteri@0.3.5)(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2) + '@astrojs/node': 11.1.1(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(supports-color@10.2.2) '@astrojs/react': 6.0.2(@types/node@26.2.0)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(esbuild@0.28.2)(jiti@2.7.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(supports-color@10.2.2)(terser@5.50.0)(yaml@2.9.0) - '@astrojs/vercel': 11.0.5(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3) + '@astrojs/vercel': 11.0.5(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(rollup@4.62.4)(supports-color@10.2.2)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3))(ws@8.21.3) '@clack/prompts': 1.7.0 '@iconify-json/lucide': 1.2.123 '@iconify/types': 2.0.0 @@ -17280,7 +17357,7 @@ snapshots: '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.4.3) '@orama/orama': 3.1.18 '@pierre/diffs': 1.3.5(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@scalar/astro': 0.4.14(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + '@scalar/astro': 0.4.14(astro@7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) '@scalar/openapi-parser': 0.28.13 '@scalar/openapi-types': 0.9.4 '@shikijs/transformers': 4.4.3 @@ -17290,7 +17367,7 @@ snapshots: '@types/mdast': 4.0.4 '@vercel/analytics': 2.0.1(next@16.3.0(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.2.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)(svelte@5.56.9(@typescript-eslint/types@8.67.0))(vue@3.5.41(typescript@6.0.3)) ai: 7.0.64(zod@4.4.3) - astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + astro: 7.2.1(@astrojs/markdown-remark@7.2.2(supports-color@10.2.2))(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@26.2.0)(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) babel-plugin-react-compiler: 1.0.0 chokidar: 5.0.0 citty: 0.1.6 @@ -17471,6 +17548,10 @@ snapshots: dependencies: '@types/node': 26.2.0 + bun-types@1.4.0: + dependencies: + '@types/node': 26.2.0 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -23050,7 +23131,7 @@ snapshots: unraw@3.0.0: {} - unstorage@1.17.5(@upstash/redis@1.38.2)(@vercel/functions@3.9.3(ws@8.21.3)): + unstorage@1.17.5(@upstash/redis@1.38.2)(@vercel/functions@3.9.5(ws@8.21.3)): dependencies: anymatch: 3.1.3 chokidar: 5.0.0 @@ -23062,7 +23143,7 @@ snapshots: ufo: 1.6.4 optionalDependencies: '@upstash/redis': 1.38.2 - '@vercel/functions': 3.9.3(ws@8.21.3) + '@vercel/functions': 3.9.5(ws@8.21.3) untyped@2.0.0: dependencies: From faa783687e078fa10aa909f3b73a27ec5c6b3734 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 09:35:10 +0700 Subject: [PATCH 02/18] refactor(cloudflare): drop the Cache API cache store --- README.md | 2 +- apps/content/docs/api-reference.mdx | 2 +- apps/content/docs/helpers/cache.mdx | 42 ------ packages/ai-sdk/README.md | 2 +- packages/arktype/README.md | 2 +- packages/bun/README.md | 2 +- packages/cache/README.md | 2 +- packages/client/README.md | 2 +- packages/cloudflare/README.md | 2 +- packages/cloudflare/src/cache-api.test.ts | 160 -------------------- packages/cloudflare/src/cache-api.ts | 170 ---------------------- packages/cloudflare/src/index.ts | 1 - packages/contract/README.md | 2 +- packages/effect/README.md | 2 +- packages/evlog/README.md | 2 +- packages/hibernation/README.md | 2 +- packages/json-schema/README.md | 2 +- packages/nest/README.md | 2 +- packages/next/README.md | 2 +- packages/node/README.md | 2 +- packages/openapi/README.md | 2 +- packages/opentelemetry/README.md | 2 +- packages/pinia-colada/README.md | 2 +- packages/pino/README.md | 2 +- packages/publisher/README.md | 2 +- packages/ratelimit/README.md | 2 +- packages/server/README.md | 2 +- packages/shared/README.md | 2 +- packages/swr/README.md | 2 +- packages/tanstack-query/README.md | 2 +- packages/trpc/README.md | 2 +- packages/valibot/README.md | 2 +- packages/zod/README.md | 2 +- 33 files changed, 29 insertions(+), 402 deletions(-) delete mode 100644 packages/cloudflare/src/cache-api.test.ts delete mode 100644 packages/cloudflare/src/cache-api.ts diff --git a/README.md b/README.md index 37ec02610..a130ab4f1 100644 --- a/README.md +++ b/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index a76b7cfb8..c0d81ae50 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -52,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 KV, Cache API, Workers Caching, Durable Object, and Rate Limit adapters. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | +| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Cloudflare KV, Workers Caching, Durable Object, and Rate Limit adapters. | [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 diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 6b5b290ef..ea29ea7d2 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -40,7 +40,6 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w | `RedisCacheStore` | [Redis](https://github.com/redis/redis) | | `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | | `CloudflareKVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | -| `CloudflareCacheApiCacheStore` | [Cloudflare Cache API](https://developers.cloudflare.com/workers/runtime-apis/cache/) with [tag purging](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/) | | `CloudflareWorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | @@ -126,47 +125,6 @@ export default { } ``` -```ts cloudflare-cache-api -import { CloudflareCacheApiCacheStore } from '@orpc/cloudflare' - -export default { - async fetch(request, env) { - // Entries are cached per data center, while revalidations - // purge their tags zone-wide through Cloudflare's Instant Purge. - const store = new CloudflareCacheApiCacheStore({ - /** - * An URL under your zone used to derive the synthetic cache keys. - */ - baseUrl: 'https://example.com/__orpc/cache', - - /** - * The zone ID used for tag purges. - */ - zoneId: env.CF_ZONE_ID, - - /** - * An API token with the Zone > Cache Purge permission. - */ - apiToken: env.CF_API_TOKEN, - - /** - * The Cache API instance to store entries in. - * - * @default caches.default - */ - cache: caches.default, - - /** - * Serializer for cached outputs. The default preserves Date, BigInt, - * Set, Map, URL, RegExp, NaN, and undefined values, and rejects - * outputs containing Blob or File values. - */ - serializer: undefined, - }) - }, -} -``` - ```ts cloudflare-workers-caching import { CloudflareWorkersCacheStore } from '@orpc/cloudflare' diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index b69e79983..ee309baf2 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 9ab77ca48..e61064f89 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/bun/README.md b/packages/bun/README.md index 5f5d3e973..71b78fec5 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -57,7 +57,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cache/README.md b/packages/cache/README.md index d65cb738e..9786fbad8 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/client/README.md b/packages/client/README.md index 37ec02610..a130ab4f1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 6d239bdf5..a91ac07cb 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/src/cache-api.test.ts b/packages/cloudflare/src/cache-api.test.ts deleted file mode 100644 index 48a2d0539..000000000 --- a/packages/cloudflare/src/cache-api.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { CloudflareCacheApiCacheStoreOptions } from './cache-api' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { CloudflareCacheApiCacheStore } from './cache-api' - -describe('cloudflareCacheApiCacheStore', () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - function createTestingStore(options: CloudflareCacheApiCacheStoreOptions = {}) { - const baseUrl = `https://example.com/__orpc/cache/${crypto.randomUUID()}` - - const store = new CloudflareCacheApiCacheStore({ - baseUrl, - zoneId: 'zone-1', - apiToken: 'token-1', - ...options, - }) - - return { store, baseUrl } - } - - function stubPurgeApi(...responses: object[]) { - const fetchFn = vi.fn() - for (const response of responses) { - fetchFn.mockResolvedValueOnce(Response.json(response)) - } - vi.stubGlobal('fetch', fetchFn) - return fetchFn - } - - it('round-trips outputs with tags and expiresAt, including undefined', async () => { - const { store } = createTestingStore() - - await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120_000 }) - - const entry = await store.get('k') - expect(entry!.output).toEqual({ nested: [1, 2] }) - expect(entry!.tags).toEqual(['t']) - expect(entry!.expiresAt).toBeGreaterThan(Date.now()) - - await store.set('u', undefined) - await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: [], expiresAt: undefined }) - }) - - it('misses on unknown keys', async () => { - const { store } = createTestingStore() - - await expect(store.get('unknown')).resolves.toBeUndefined() - }) - - it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { - const { store } = createTestingStore() - const output = { - date: new Date('2026-01-02T03:04:05.678Z'), - map: new Map([['a', 1]]), - set: new Set([1, 2]), - big: 123n, - nothing: undefined, - } - - await store.set('k', output) - - await expect(store.get('k')).resolves.toMatchObject({ output }) - }) - - it('rejects outputs containing blobs', async () => { - const { store } = createTestingStore() - - await expect( - store.set('k', { file: new Blob(['x']) }), - ).rejects.toThrow('CloudflareCacheApiCacheStore cannot cache outputs containing Blob or File values') - }) - - it('supports a custom serializer', async () => { - const serializer = { - stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), - parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), - } - const { store } = createTestingStore({ serializer }) - - await store.set('k', { a: 1 }) - - await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) - expect(serializer.stringify).toHaveBeenCalled() - expect(serializer.parse).toHaveBeenCalled() - }) - - it('stores entries with encoded Cache-Tag headers and second-based retention', async () => { - const { store, baseUrl } = createTestingStore() - - await store.set('k', 'v', { tags: ['a,b', 'planets'], ttl: 1000, swr: 500 }) - - const stored = await caches.default.match(`${baseUrl}/k`) - expect(stored!.headers.get('cache-tag')).toBe('a%2Cb,planets') - expect(stored!.headers.get('cache-control')).toBe('public, s-maxage=2') - - await store.set('forever', 'v') - - const foreverStored = await caches.default.match(`${baseUrl}/forever`) - expect(foreverStored!.headers.get('cache-tag')).toBe(null) - expect(foreverStored!.headers.get('cache-control')).toBe('public, s-maxage=31536000') - }) - - it('serves stale entries within the swr window, then evicts at the exact bound', async () => { - const { store, baseUrl } = createTestingStore() - - const envelope = (expiresAt: number, evictAt: number) => new Response( - JSON.stringify({ output: JSON.stringify({ json: 'v' }), tags: [], expiresAt, evictAt }), - { headers: { 'cache-control': 'public, s-maxage=3600' } }, - ) - - await caches.default.put(`${baseUrl}/stale`, envelope(Date.now() - 1000, Date.now() + 60_000)) - await caches.default.put(`${baseUrl}/evicted`, envelope(Date.now() - 2000, Date.now() - 1000)) - - const stale = await store.get('stale') - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(Date.now()) - - await expect(store.get('evicted')).resolves.toBeUndefined() - await expect(caches.default.match(`${baseUrl}/evicted`)).resolves.toBeUndefined() - }) - - it('purges tags zone-wide through the purge API', async () => { - const { store } = createTestingStore() - const fetchFn = stubPurgeApi({ success: true }) - - await store.revalidateTag(['planets', 'a,b']) - - expect(fetchFn).toHaveBeenCalledTimes(1) - expect(fetchFn).toHaveBeenCalledWith('https://api.cloudflare.com/client/v4/zones/zone-1/purge_cache', { - method: 'POST', - headers: { - 'authorization': 'Bearer token-1', - 'content-type': 'application/json', - }, - body: JSON.stringify({ tags: ['planets', 'a%2Cb'] }), - }) - }) - - it('splits large purges into batches of 100 tags', async () => { - const { store } = createTestingStore() - const fetchFn = stubPurgeApi({ success: true }, { success: true }) - - await store.revalidateTag(Array.from({ length: 150 }, (_, i) => `tag-${i}`) as [string, ...string[]]) - - expect(fetchFn).toHaveBeenCalledTimes(2) - expect(JSON.parse(fetchFn.mock.calls[0]![1]!.body as string).tags).toHaveLength(100) - expect(JSON.parse(fetchFn.mock.calls[1]![1]!.body as string).tags).toHaveLength(50) - }) - - it('throws when the purge API reports a failure', async () => { - const { store } = createTestingStore() - stubPurgeApi({ success: false, errors: [{ message: 'Invalid API token' }] }) - - await expect(store.revalidateTag('planets')).rejects.toThrow( - 'CloudflareCacheApiCacheStore failed to purge tags (status 200): Invalid API token', - ) - }) -}) diff --git a/packages/cloudflare/src/cache-api.ts b/packages/cloudflare/src/cache-api.ts deleted file mode 100644 index cd65cf598..000000000 --- a/packages/cloudflare/src/cache-api.ts +++ /dev/null @@ -1,170 +0,0 @@ -import type { CacheEntry, CacheOutputSerializer, CacheSetOptions, CacheStore } from '@orpc/cache' -import { createRpcJsonOutputSerializer, encodeCacheTagHeader } from '@orpc/cache' -import { stringifyJSON, toArray } from '@orpc/shared' - -/** - * The purge API accepts a limited number of tags per call, so larger - * revalidations are split into batches of this size. - */ -const PURGE_TAGS_BATCH_SIZE = 100 - -interface CloudflareCacheApiCacheStoreEnvelope { - /** - * The cached output, encoded with the store's serializer. - */ - output: string - tags: readonly string[] - expiresAt?: number | undefined - evictAt?: number | undefined -} - -export interface CloudflareCacheApiCacheStoreOptions { - /** - * The Cache API instance to store entries in. - * - * @default caches.default - */ - cache?: Cache - - /** - * Serializer for cached outputs. - * - * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values - */ - serializer?: CacheOutputSerializer -} - -/** - * Cache store adapter for the Cloudflare Cache API with tag purging through - * Cloudflare's purge API. Entries are stored per data center under synthetic - * URLs below `baseUrl` and carry their tags in a `Cache-Tag` header; - * revalidations purge those tags zone-wide via Instant Purge. - * - * @remarks - * **Note**: `baseUrl` must live under the purged zone, the API token needs - * the Zone > Cache Purge permission, and purge calls are subject to your - * plan's purge rate limits. The Cache API is inert in dashboard previews and - * each data center caches entries independently. - * - * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} - */ -export class CloudflareCacheApiCacheStore implements CacheStore { - private readonly baseUrl: string - private readonly zoneId: string - private readonly apiToken: string - private readonly cache: Cache | undefined - private readonly serializer: CacheOutputSerializer - - constructor( - options: { - /** - * An URL under your zone used to derive the synthetic cache keys, - * e.g. `https://example.com/__orpc/cache`. - */ - baseUrl: string - - /** - * The zone ID used for tag purges. - */ - zoneId: string - - /** - * An API token with the Zone > Cache Purge permission. - */ - apiToken: string - } & CloudflareCacheApiCacheStoreOptions, - ) { - this.baseUrl = options.baseUrl.replace(/\/$/, '') - this.zoneId = options.zoneId - this.apiToken = options.apiToken - this.cache = options.cache - this.serializer = options.serializer ?? createRpcJsonOutputSerializer('CloudflareCacheApiCacheStore') - } - - async get(key: string): Promise { - const response = await this.resolveCache().match(this.entryUrl(key)) - - if (!response) { - return undefined - } - - const envelope = await response.json() as CloudflareCacheApiCacheStoreEnvelope - - if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { - await this.resolveCache().delete(this.entryUrl(key)) - return undefined - } - - return { - output: this.serializer.parse(envelope.output), - tags: envelope.tags, - expiresAt: envelope.expiresAt, - } - } - - async set(key: string, output: unknown, options?: CacheSetOptions): Promise { - const tags = options?.tags ?? [] - const serialized = this.serializer.stringify(output) - - const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined - const evictAt = retention !== undefined ? Date.now() + retention : undefined - - const envelope: CloudflareCacheApiCacheStoreEnvelope = { - output: serialized, - tags, - expiresAt, - evictAt, - } - - const headers: Record = { - /** - * The Cache API is ephemeral, so entries without a ttl are retained - * for a year and evicted earlier under storage pressure. - */ - 'cache-control': `public, s-maxage=${retention !== undefined ? Math.ceil(retention / 1000) : 31536000}`, - } - - if (tags.length) { - headers['cache-tag'] = encodeCacheTagHeader(tags) - } - - await this.resolveCache().put(this.entryUrl(key), new Response(stringifyJSON(envelope), { headers })) - } - - async revalidateTag(tag: string | readonly string[]): Promise { - const tags = toArray(tag) - - for (let i = 0; i < tags.length; i += PURGE_TAGS_BATCH_SIZE) { - const batch = tags.slice(i, i + PURGE_TAGS_BATCH_SIZE) - - const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${this.zoneId}/purge_cache`, { - method: 'POST', - headers: { - 'authorization': `Bearer ${this.apiToken}`, - 'content-type': 'application/json', - }, - body: stringifyJSON({ - // Tags must match the stored Cache-Tag header, so each one is encoded the same way. - tags: batch.map(t => encodeCacheTagHeader([t])), - }), - }) - - const result = await response.json() as { success?: boolean, errors?: { message?: string }[] } - - if (!response.ok || !result.success) { - const messages = toArray(result.errors).map(error => error.message).filter(Boolean).join('; ') - throw new Error(`CloudflareCacheApiCacheStore failed to purge tags (status ${response.status})${messages ? `: ${messages}` : ''}`) - } - } - } - - private resolveCache(): Cache { - // Cast because Cloudflare's `caches.default` is not part of the standard CacheStorage typings. - return this.cache ?? (caches as unknown as { default: Cache }).default - } - - private entryUrl(key: string): string { - return `${this.baseUrl}/${encodeURIComponent(key)}` - } -} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 4669008a2..5e46803de 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,5 +1,4 @@ export * from './cache' -export * from './cache-api' export * from './publisher' export * from './publisher-object' export * from './ratelimit' diff --git a/packages/contract/README.md b/packages/contract/README.md index d0fa062ca..ecf3e8c17 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/effect/README.md b/packages/effect/README.md index 846bd8b4b..7c4d2a9ae 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index fd45d6d5e..0e166a4f4 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index adcab1b1c..4ca241502 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index fe434b630..ea96a7012 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/nest/README.md b/packages/nest/README.md index f8371a9d1..f7e0a69d4 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/next/README.md b/packages/next/README.md index 3269dac00..305ea66fb 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/node/README.md b/packages/node/README.md index 51012f7bc..5c77d9f69 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index abf5bd64c..59933fec9 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 6b6292c56..f7410ec06 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 14cb654f9..69156b997 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pino/README.md b/packages/pino/README.md index 8363abcec..d045f2d5d 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index fb5258bb6..0dfac8e40 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 6db92d7d5..2d60cb780 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/server/README.md b/packages/server/README.md index 94d93b527..99223acb3 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/shared/README.md b/packages/shared/README.md index 28f241040..a43b30107 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/swr/README.md b/packages/swr/README.md index 29764aa34..8e471a9a6 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index f7c285236..cb0b28cc6 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index d19516a70..ce6fbb36b 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index 8d431a8ea..d442fa54b 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/zod/README.md b/packages/zod/README.md index e67a807ab..6906f660a 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -60,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 KV, Cache API, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's KV, Workers Caching, RateLimit, and Durable Objects](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** From 9b503adfba3db0c9214b5d0f2c590be1d684869e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 09:42:44 +0700 Subject: [PATCH 03/18] refactor(cache): default store key prefixes to none --- apps/content/docs/helpers/cache.mdx | 8 ++++---- packages/cache/src/adapters/redis.ts | 4 ++-- packages/cloudflare/src/cache.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index ea29ea7d2..250c9f08b 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -64,9 +64,9 @@ const store = new RedisCacheStore(client, { /** * The prefix to use for Redis keys. * - * @default 'orpc:cache:' + * @default undefined */ - prefix: 'orpc:cache:', + prefix: undefined, /** * Serializer for cached outputs. The default preserves Date, BigInt, @@ -110,9 +110,9 @@ export default { /** * The prefix to use for KV keys. * - * @default 'orpc:cache:' + * @default undefined */ - prefix: 'orpc:cache:', + prefix: undefined, /** * Serializer for cached outputs. The default preserves Date, BigInt, diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index e55b64568..f49c45c42 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -23,7 +23,7 @@ export interface RedisCacheStoreOptions { /** * The prefix to use for Redis keys. * - * @default 'orpc:cache:' + * @default undefined */ prefix?: string @@ -53,7 +53,7 @@ export class RedisCacheStore implements CacheStore { options: RedisCacheStoreOptions = {}, ) { this.redis = redis - this.prefix = options.prefix ?? 'orpc:cache:' + this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? createRpcJsonOutputSerializer('RedisCacheStore') } diff --git a/packages/cloudflare/src/cache.ts b/packages/cloudflare/src/cache.ts index 27e6fe705..6952bcf88 100644 --- a/packages/cloudflare/src/cache.ts +++ b/packages/cloudflare/src/cache.ts @@ -22,7 +22,7 @@ export interface CloudflareKVCacheStoreOptions { /** * The prefix to use for KV keys. * - * @default 'orpc:cache:' + * @default undefined */ prefix?: string @@ -58,7 +58,7 @@ export class CloudflareKVCacheStore implements CacheStore { options: CloudflareKVCacheStoreOptions = {}, ) { this.kv = kv - this.prefix = options.prefix ?? 'orpc:cache:' + this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? createRpcJsonOutputSerializer('CloudflareKVCacheStore') } From 93288dd88e647d4fee508bbd0f9a5f25209f7352 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 09:47:50 +0700 Subject: [PATCH 04/18] refactor(cloudflare): rename cache stores to KVCacheStore and WorkersCacheStore --- apps/content/docs/helpers/cache.mdx | 14 +++++++------- packages/cloudflare/package.json | 2 +- packages/cloudflare/src/index.ts | 2 +- .../src/{cache.test.ts => kv-cache.test.ts} | 12 ++++++------ packages/cloudflare/src/{cache.ts => kv-cache.ts} | 14 +++++++------- packages/cloudflare/src/workers-cache.test.ts | 14 +++++++------- packages/cloudflare/src/workers-cache.ts | 8 ++++---- 7 files changed, 33 insertions(+), 33 deletions(-) rename packages/cloudflare/src/{cache.test.ts => kv-cache.test.ts} (91%) rename packages/cloudflare/src/{cache.ts => kv-cache.ts} (91%) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 250c9f08b..3ff8f6868 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -39,8 +39,8 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w | `MemoryCacheStore` | In-memory storage | | `RedisCacheStore` | [Redis](https://github.com/redis/redis) | | `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | -| `CloudflareKVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | -| `CloudflareWorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | +| `KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | +| `WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | @@ -100,13 +100,13 @@ const store = new VercelCacheStore({ ``` ```ts cloudflare-kv -import { CloudflareKVCacheStore } from '@orpc/cloudflare' +import { 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 CloudflareKVCacheStore(env.CACHE_KV, { + const store = new KVCacheStore(env.CACHE_KV, { /** * The prefix to use for KV keys. * @@ -126,7 +126,7 @@ export default { ``` ```ts cloudflare-workers-caching -import { CloudflareWorkersCacheStore } from '@orpc/cloudflare' +import { WorkersCacheStore } from '@orpc/cloudflare' export default { async fetch(request, env, ctx) { @@ -136,7 +136,7 @@ export default { // 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 CloudflareWorkersCacheStore(ctx.cache) + const store = new WorkersCacheStore(ctx.cache) }, } ``` @@ -252,7 +252,7 @@ const handler = new RPCHandler(router, { ``` :::info[Response Caches in Front] -With `httpCacheHeaders` enabled, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `CloudflareWorkersCacheStore`, 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. +With `httpCacheHeaders` enabled, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `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 diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index bf804e91f..41f810dd6 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -2,7 +2,7 @@ "name": "@orpc/cloudflare", "type": "module", "version": "2.0.0-beta.31", - "description": "Cloudflare integration for oRPC: KV, Cache API, and Workers Caching cache stores, Durable Object pub/sub, and Workers rate limiting adapters", + "description": "Cloudflare integration for oRPC: KV and Workers Caching cache stores, Durable Object pub/sub, and Workers rate limiting adapters", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 5e46803de..128ce5df9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,4 +1,4 @@ -export * from './cache' +export * from './kv-cache' export * from './publisher' export * from './publisher-object' export * from './ratelimit' diff --git a/packages/cloudflare/src/cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts similarity index 91% rename from packages/cloudflare/src/cache.test.ts rename to packages/cloudflare/src/kv-cache.test.ts index cc961e7f4..240b196c7 100644 --- a/packages/cloudflare/src/cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -1,12 +1,12 @@ -import type { CloudflareKVCacheStoreOptions } from './cache' +import type { KVCacheStoreOptions } from './kv-cache' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' -import { CloudflareKVCacheStore } from './cache' +import { KVCacheStore } from './kv-cache' -describe('cloudflareKVCacheStore', () => { - function createTestingStore(options: CloudflareKVCacheStoreOptions = {}) { +describe('kvCacheStore', () => { + function createTestingStore(options: KVCacheStoreOptions = {}) { const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - return { store: new CloudflareKVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } + return { store: new KVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } } it('round-trips outputs with tags and expiresAt, including undefined', async () => { @@ -49,7 +49,7 @@ describe('cloudflareKVCacheStore', () => { await expect( store.set('k', { file: new Blob(['x']) }), - ).rejects.toThrow('CloudflareKVCacheStore cannot cache outputs containing Blob or File values') + ).rejects.toThrow('KVCacheStore cannot cache outputs containing Blob or File values') }) it('supports a custom serializer', async () => { diff --git a/packages/cloudflare/src/cache.ts b/packages/cloudflare/src/kv-cache.ts similarity index 91% rename from packages/cloudflare/src/cache.ts rename to packages/cloudflare/src/kv-cache.ts index 6952bcf88..ab4e02c74 100644 --- a/packages/cloudflare/src/cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -2,7 +2,7 @@ import type { CacheEntry, CacheOutputSerializer, CacheSetOptions, CacheStore } f import { createRpcJsonOutputSerializer } from '@orpc/cache' import { stringifyJSON, toArray } from '@orpc/shared' -interface CloudflareKVCacheStoreEnvelope { +interface KVCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. */ @@ -18,7 +18,7 @@ interface CloudflareKVCacheStoreEnvelope { evictAt?: number | undefined } -export interface CloudflareKVCacheStoreOptions { +export interface KVCacheStoreOptions { /** * The prefix to use for KV keys. * @@ -48,22 +48,22 @@ export interface CloudflareKVCacheStoreOptions { * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class CloudflareKVCacheStore implements CacheStore { +export class KVCacheStore implements CacheStore { private readonly kv: KVNamespace private readonly prefix: string private readonly serializer: CacheOutputSerializer constructor( kv: KVNamespace, - options: CloudflareKVCacheStoreOptions = {}, + options: KVCacheStoreOptions = {}, ) { this.kv = kv this.prefix = options.prefix ?? '' - this.serializer = options.serializer ?? createRpcJsonOutputSerializer('CloudflareKVCacheStore') + this.serializer = options.serializer ?? createRpcJsonOutputSerializer('KVCacheStore') } async get(key: string): Promise { - const envelope = await this.kv.get(this.entryKey(key), 'json') + const envelope = await this.kv.get(this.entryKey(key), 'json') if (envelope === null) { return undefined @@ -110,7 +110,7 @@ export class CloudflareKVCacheStore implements CacheStore { const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined const evictAt = retention !== undefined ? Date.now() + retention : undefined - const envelope: CloudflareKVCacheStoreEnvelope = { + const envelope: KVCacheStoreEnvelope = { output: serialized, tags, tagTokens, diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 54c7869de..1ebff6548 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { CloudflareWorkersCacheStore } from './workers-cache' +import { WorkersCacheStore } from './workers-cache' -describe('cloudflareWorkersCacheStore', () => { +describe('workersCacheStore', () => { const createPurger = () => ({ purge: vi.fn(async () => ({ success: true })), }) it('always misses and stores nothing', async () => { const purger = createPurger() - const store = new CloudflareWorkersCacheStore(purger) + const store = new WorkersCacheStore(purger) await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) await expect(store.get('k')).resolves.toBeUndefined() @@ -17,7 +17,7 @@ describe('cloudflareWorkersCacheStore', () => { it('purges encoded tags through workers caching', async () => { const purger = createPurger() - const store = new CloudflareWorkersCacheStore(purger) + const store = new WorkersCacheStore(purger) await store.revalidateTag(['planets', 'a,b']) @@ -27,7 +27,7 @@ describe('cloudflareWorkersCacheStore', () => { it('accepts a single tag', async () => { const purger = createPurger() - const store = new CloudflareWorkersCacheStore(purger) + const store = new WorkersCacheStore(purger) await store.revalidateTag('planets') @@ -38,10 +38,10 @@ describe('cloudflareWorkersCacheStore', () => { const purger = { purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), } - const store = new CloudflareWorkersCacheStore(purger) + const store = new WorkersCacheStore(purger) await expect(store.revalidateTag('planets')).rejects.toThrow( - 'CloudflareWorkersCacheStore failed to purge tags: Rate limited', + 'WorkersCacheStore failed to purge tags: Rate limited', ) }) }) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index db48f2165..3914b953b 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -8,7 +8,7 @@ import { toArray } from '@orpc/shared' * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export interface CloudflareWorkersCachePurger { +export interface WorkersCachePurger { purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> } @@ -26,9 +26,9 @@ export interface CloudflareWorkersCachePurger { * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class CloudflareWorkersCacheStore implements CacheStore { +export class WorkersCacheStore implements CacheStore { constructor( - private readonly cache: CloudflareWorkersCachePurger, + private readonly cache: WorkersCachePurger, ) {} async get(_key: string): Promise { @@ -53,7 +53,7 @@ export class CloudflareWorkersCacheStore implements CacheStore { if (!result.success) { const messages = toArray(result.errors).map(error => error.message).filter(Boolean).join('; ') - throw new Error(`CloudflareWorkersCacheStore failed to purge tags${messages ? `: ${messages}` : ''}`) + throw new Error(`WorkersCacheStore failed to purge tags${messages ? `: ${messages}` : ''}`) } } } From f35e3ad4e8c25ad38f8fecfef1dd76c761231385 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 09:56:33 +0700 Subject: [PATCH 05/18] refactor(cache): unify store constructors on a single options object --- apps/content/docs/helpers/cache.mdx | 16 +++++++++++++--- packages/cache/src/adapters/redis.test.ts | 6 +++--- packages/cache/src/adapters/redis.ts | 12 +++++++----- packages/cloudflare/src/kv-cache.test.ts | 4 ++-- packages/cloudflare/src/kv-cache.ts | 12 +++++++----- packages/cloudflare/src/workers-cache.test.ts | 8 ++++---- packages/cloudflare/src/workers-cache.ts | 16 +++++++++++++--- 7 files changed, 49 insertions(+), 25 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 3ff8f6868..7d82ed3f5 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -60,7 +60,12 @@ const client = createClient({ url: 'redis://localhost:6379' }) // You can still call `client.connect()` manually, but it is optional. await client.connect() -const store = new RedisCacheStore(client, { +const store = new RedisCacheStore({ + /** + * The Redis client to store entries in. Connected lazily when needed. + */ + redis: client, + /** * The prefix to use for Redis keys. * @@ -106,7 +111,12 @@ 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(env.CACHE_KV, { + const store = new KVCacheStore({ + /** + * The KV namespace to store entries in. + */ + kv: env.CACHE_KV, + /** * The prefix to use for KV keys. * @@ -136,7 +146,7 @@ export default { // 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(ctx.cache) + const store = new WorkersCacheStore({ cache: ctx.cache }) }, } ``` diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 26a01fe85..b94daa9ea 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -17,10 +17,10 @@ describe.concurrent('redis cache store integration', { }) function createTestingStore( - options: ConstructorParameters[1] = {}, + options: Partial[0]> = {}, ) { const prefix = `orpc-redis-cache-store-${crypto.randomUUID()}:` - return { store: new RedisCacheStore(redis, { prefix, ...options }), prefix } + return { store: new RedisCacheStore({ redis, prefix, ...options }), prefix } } it('round-trips outputs with tags and expiresAt', async () => { @@ -138,7 +138,7 @@ describe.concurrent('redis cache store integration', { it('lazily connects a closed client', async () => { const lazyRedis = createClient({ url: REDIS_URL }) - const store = new RedisCacheStore(lazyRedis, { prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) + const store = new RedisCacheStore({ redis: lazyRedis, prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) expect(lazyRedis.isOpen).toBe(false) await expect(store.get('unknown')).resolves.toBeUndefined() diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index f49c45c42..55d50bca8 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -20,6 +20,11 @@ interface RedisCacheStoreEnvelope { } export interface RedisCacheStoreOptions { + /** + * The Redis client to store entries in. Connected lazily when needed. + */ + redis: RedisClientType + /** * The prefix to use for Redis keys. * @@ -48,11 +53,8 @@ export class RedisCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: RedisCacheStoreSerializer - constructor( - redis: RedisClientType, - options: RedisCacheStoreOptions = {}, - ) { - this.redis = redis + constructor(options: RedisCacheStoreOptions) { + this.redis = options.redis this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? createRpcJsonOutputSerializer('RedisCacheStore') } diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index 240b196c7..ffd54ac7b 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -4,9 +4,9 @@ import { describe, expect, it, vi } from 'vitest' import { KVCacheStore } from './kv-cache' describe('kvCacheStore', () => { - function createTestingStore(options: KVCacheStoreOptions = {}) { + function createTestingStore(options: Partial = {}) { const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - return { store: new KVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } + return { store: new KVCacheStore({ kv: env.CACHE_KV, prefix, ...options }), prefix } } it('round-trips outputs with tags and expiresAt, including undefined', async () => { diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index ab4e02c74..9faaa3fd4 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -19,6 +19,11 @@ interface KVCacheStoreEnvelope { } export interface KVCacheStoreOptions { + /** + * The KV namespace to store entries in. + */ + kv: KVNamespace + /** * The prefix to use for KV keys. * @@ -53,11 +58,8 @@ export class KVCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: CacheOutputSerializer - constructor( - kv: KVNamespace, - options: KVCacheStoreOptions = {}, - ) { - this.kv = kv + constructor(options: KVCacheStoreOptions) { + this.kv = options.kv this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? createRpcJsonOutputSerializer('KVCacheStore') } diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 1ebff6548..4d5be2ec0 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -8,7 +8,7 @@ describe('workersCacheStore', () => { it('always misses and stores nothing', async () => { const purger = createPurger() - const store = new WorkersCacheStore(purger) + const store = new WorkersCacheStore({ cache: purger }) await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) await expect(store.get('k')).resolves.toBeUndefined() @@ -17,7 +17,7 @@ describe('workersCacheStore', () => { it('purges encoded tags through workers caching', async () => { const purger = createPurger() - const store = new WorkersCacheStore(purger) + const store = new WorkersCacheStore({ cache: purger }) await store.revalidateTag(['planets', 'a,b']) @@ -27,7 +27,7 @@ describe('workersCacheStore', () => { it('accepts a single tag', async () => { const purger = createPurger() - const store = new WorkersCacheStore(purger) + const store = new WorkersCacheStore({ cache: purger }) await store.revalidateTag('planets') @@ -38,7 +38,7 @@ describe('workersCacheStore', () => { const purger = { purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), } - const store = new WorkersCacheStore(purger) + const store = new WorkersCacheStore({ cache: purger }) await expect(store.revalidateTag('planets')).rejects.toThrow( 'WorkersCacheStore failed to purge tags: Rate limited', diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index 3914b953b..e734110ed 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -12,6 +12,14 @@ export interface WorkersCachePurger { purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> } +export interface WorkersCacheStoreOptions { + /** + * The Workers Caching purge surface: `ctx.cache` or `cache` imported + * from `cloudflare:workers`. + */ + cache: WorkersCachePurger +} + /** * Purge-only cache store for Cloudflare Workers Caching. Responses are cached * in front of the Worker through `Cache-Control` and `Cache-Tag` headers (see @@ -27,9 +35,11 @@ export interface WorkersCachePurger { * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class WorkersCacheStore implements CacheStore { - constructor( - private readonly cache: WorkersCachePurger, - ) {} + private readonly cache: WorkersCachePurger + + constructor(options: WorkersCacheStoreOptions) { + this.cache = options.cache + } async get(_key: string): Promise { return undefined From 6c3f4ddf1b83e88878813644eeff6a18a0eb2fca Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 09:58:16 +0700 Subject: [PATCH 06/18] chore(cloudflare): shorten package description --- packages/cloudflare/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 41f810dd6..6b17656a7 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -2,7 +2,7 @@ "name": "@orpc/cloudflare", "type": "module", "version": "2.0.0-beta.31", - "description": "Cloudflare integration for oRPC: KV and Workers Caching cache stores, Durable Object pub/sub, and Workers rate limiting adapters", + "description": "oRPC adapters for Cloudflare Workers", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", From 8d5c3569a3ee39ab9424e8becbf5300b6f89bf7c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:00:26 +0700 Subject: [PATCH 07/18] chore: shorten the cloudflare package description in package lists --- README.md | 2 +- apps/content/docs/api-reference.mdx | 2 +- packages/ai-sdk/README.md | 2 +- packages/arktype/README.md | 2 +- packages/bun/README.md | 2 +- packages/cache/README.md | 2 +- packages/client/README.md | 2 +- packages/cloudflare/README.md | 2 +- packages/contract/README.md | 2 +- packages/effect/README.md | 2 +- packages/evlog/README.md | 2 +- packages/hibernation/README.md | 2 +- packages/json-schema/README.md | 2 +- packages/nest/README.md | 2 +- packages/next/README.md | 2 +- packages/node/README.md | 2 +- packages/openapi/README.md | 2 +- packages/opentelemetry/README.md | 2 +- packages/pinia-colada/README.md | 2 +- packages/pino/README.md | 2 +- packages/publisher/README.md | 2 +- packages/ratelimit/README.md | 2 +- packages/server/README.md | 2 +- packages/shared/README.md | 2 +- packages/swr/README.md | 2 +- packages/tanstack-query/README.md | 2 +- packages/trpc/README.md | 2 +- packages/valibot/README.md | 2 +- packages/zod/README.md | 2 +- 29 files changed, 29 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index a130ab4f1..3d3b869f7 100644 --- a/README.md +++ b/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index c0d81ae50..e42693b7a 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -52,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 KV, Workers Caching, Durable Object, and Rate Limit adapters. | [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) | | [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | ## Observability diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index ee309baf2..97d6b24b4 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index e61064f89..1c46709d6 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/bun/README.md b/packages/bun/README.md index 71b78fec5..c502fa070 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -57,7 +57,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 KV, Workers Caching, 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** diff --git a/packages/cache/README.md b/packages/cache/README.md index 9786fbad8..9d2ad716a 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/client/README.md b/packages/client/README.md index a130ab4f1..3d3b869f7 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index a91ac07cb..1e833dcca 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/contract/README.md b/packages/contract/README.md index ecf3e8c17..87b208095 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/effect/README.md b/packages/effect/README.md index 7c4d2a9ae..3252b5a74 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index 0e166a4f4..e1f006813 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 4ca241502..5a649e6c9 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index ea96a7012..422052e39 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/nest/README.md b/packages/nest/README.md index f7e0a69d4..ea551bb95 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/next/README.md b/packages/next/README.md index 305ea66fb..9bc6384a2 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/node/README.md b/packages/node/README.md index 5c77d9f69..140622d16 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 59933fec9..5ae24dc7c 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index f7410ec06..71c353be0 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 69156b997..b11e1914f 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/pino/README.md b/packages/pino/README.md index d045f2d5d..67b9c6732 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 0dfac8e40..69c161193 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 2d60cb780..ac25b0ce9 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/server/README.md b/packages/server/README.md index 99223acb3..db9056667 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/shared/README.md b/packages/shared/README.md index a43b30107..e0156d9f2 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/swr/README.md b/packages/swr/README.md index 8e471a9a6..63dee0044 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index cb0b28cc6..9925729d3 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index ce6fbb36b..36d51aaf1 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index d442fa54b..e414c779f 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -60,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 KV, Workers Caching, 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** diff --git a/packages/zod/README.md b/packages/zod/README.md index 6906f660a..aad0c5185 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -60,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 KV, Workers Caching, 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** From 71cf231e3ec8d05ef6c6f3a5379d2a1f6ee883b9 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:14:34 +0700 Subject: [PATCH 08/18] refactor(cache): serialize outputs with RPCSerializer and ignore blob outputs --- apps/content/docs/helpers/cache.mdx | 21 ++++++---- .../cache/src/adapters/output-serializer.ts | 42 ------------------- packages/cache/src/adapters/redis.test.ts | 23 +++++----- packages/cache/src/adapters/redis.ts | 31 ++++++++------ packages/cache/src/adapters/vercel.test.ts | 23 +++++----- packages/cache/src/adapters/vercel.ts | 32 ++++++++------ packages/cache/src/index.ts | 1 - packages/cloudflare/src/kv-cache.test.ts | 25 ++++++----- packages/cloudflare/src/kv-cache.ts | 30 ++++++++----- 9 files changed, 102 insertions(+), 126 deletions(-) delete mode 100644 packages/cache/src/adapters/output-serializer.ts diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 7d82ed3f5..60c5bfb95 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -74,9 +74,10 @@ const store = new RedisCacheStore({ prefix: undefined, /** - * Serializer for cached outputs. The default preserves Date, BigInt, - * Set, Map, URL, RegExp, NaN, and undefined values, and rejects - * outputs containing Blob or File values. + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer */ serializer: undefined, }) @@ -96,9 +97,10 @@ const store = new VercelCacheStore({ cache: getCache(), /** - * Serializer for cached outputs. The default preserves Date, BigInt, - * Set, Map, URL, RegExp, NaN, and undefined values, and rejects - * outputs containing Blob or File values. + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer */ serializer: undefined, }) @@ -125,9 +127,10 @@ export default { prefix: undefined, /** - * Serializer for cached outputs. The default preserves Date, BigInt, - * Set, Map, URL, RegExp, NaN, and undefined values, and rejects - * outputs containing Blob or File values. + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer */ serializer: undefined, }) diff --git a/packages/cache/src/adapters/output-serializer.ts b/packages/cache/src/adapters/output-serializer.ts deleted file mode 100644 index 215f1e7b0..000000000 --- a/packages/cache/src/adapters/output-serializer.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { RPCJsonSerialization } from '@orpc/client' -import { RPCJsonSerializer } from '@orpc/client' -import { stringifyJSON } from '@orpc/shared' - -/** - * Serializes cached outputs to strings and back, used by cache store adapters. - * - * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} - */ -export interface CacheOutputSerializer { - stringify(data: unknown): string - parse(text: string): unknown -} - -/** - * Creates the default output serializer for cache store adapters, backed by - * the RPC JSON serializer so Date, BigInt, Set, Map, URL, RegExp, NaN, and - * undefined values survive the round trip. Outputs containing Blob or File - * values are rejected with a TypeError naming `storeName`. - * - * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} - */ -export function createRpcJsonOutputSerializer(storeName: string): CacheOutputSerializer { - // Cached outputs should round-trip exactly, so undefined properties are kept - // even though the RPC protocol omits them over the wire. - const jsonSerializer = new RPCJsonSerializer({ omitUndefinedProperties: false }) - - return { - stringify(data) { - const { json, meta, maps, blobs } = jsonSerializer.serialize(data) - - if (blobs?.length) { - throw new TypeError(`${storeName} cannot cache outputs containing Blob or File values`) - } - - return stringifyJSON({ json, meta, maps }) - }, - parse(text) { - return jsonSerializer.deserialize(JSON.parse(text) as RPCJsonSerialization) - }, - } -} diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index b94daa9ea..519731bcb 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -1,3 +1,4 @@ +import { RPCSerializer } from '@orpc/client' import { sleep } from '@orpc/shared' import { createClient } from 'redis' import { RedisCacheStore } from './redis' @@ -40,14 +41,13 @@ describe.concurrent('redis cache store integration', { await expect(store.get('unknown')).resolves.toBeUndefined() }) - it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + it('preserves Date, Map, Set, and BigInt outputs', async () => { const { store } = createTestingStore() const output = { date: new Date('2026-01-02T03:04:05.678Z'), map: new Map([['a', 1]]), set: new Set([1, 2]), big: 123n, - nothing: undefined, } await store.set('k', output) @@ -55,26 +55,25 @@ describe.concurrent('redis cache store integration', { await expect(store.get('k')).resolves.toMatchObject({ output }) }) - it('rejects outputs containing blobs', async () => { + it('ignores outputs containing blobs', async () => { const { store } = createTestingStore() - await expect( - store.set('k', { file: new Blob(['x']) }), - ).rejects.toThrow('RedisCacheStore cannot cache outputs containing Blob or File values') + await store.set('k', { file: new Blob(['x']) }) + + await expect(store.get('k')).resolves.toBeUndefined() }) it('supports a custom serializer', async () => { - const serializer = { - stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), - parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), - } + const serializer = new RPCSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') const { store } = createTestingStore({ serializer }) await store.set('k', { a: 1 }) await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) - expect(serializer.stringify).toHaveBeenCalled() - expect(serializer.parse).toHaveBeenCalled() + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() }) it('evicts at ttl without swr, and serves stale within the swr window', async () => { diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index 55d50bca8..f11a3c270 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -1,16 +1,14 @@ +import type { Public } from '@orpc/shared' import type { RedisClientType } from 'redis' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' -import type { CacheOutputSerializer } from './output-serializer' -import { stringifyJSON, toArray } from '@orpc/shared' -import { createRpcJsonOutputSerializer } from './output-serializer' - -export type RedisCacheStoreSerializer = CacheOutputSerializer +import { RPCSerializer } from '@orpc/client' +import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface RedisCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. */ - output: string + output: unknown tags: readonly string[] /** * Tag version counters snapshotted at set time. @@ -35,28 +33,29 @@ export interface RedisCacheStoreOptions { /** * Serializer for cached outputs. * - * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + * @default RPCSerializer */ - serializer?: RedisCacheStoreSerializer + serializer?: undefined | Public } /** * Cache store adapter for Redis with tag-based invalidation. Entries are * retained for `ttl + swr` via `PX` expiry; tag counters have no expiry * since expiring one would resurrect stale entries. Revalidated entries - * are removed lazily on the next `get` of their key. + * are removed lazily on the next `get` of their key. Outputs containing + * Blob or File values are ignored and never stored. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class RedisCacheStore implements CacheStore { private readonly redis: RedisClientType private readonly prefix: string - private readonly serializer: RedisCacheStoreSerializer + private readonly serializer: Public constructor(options: RedisCacheStoreOptions) { this.redis = options.redis this.prefix = options.prefix ?? '' - this.serializer = options.serializer ?? createRpcJsonOutputSerializer('RedisCacheStore') + this.serializer = options.serializer ?? new RPCSerializer() } async get(key: string): Promise { @@ -84,17 +83,23 @@ export class RedisCacheStore implements CacheStore { } return { - output: this.serializer.parse(envelope.output), + output: this.serializer.deserialize(envelope.output as any), tags: envelope.tags, expiresAt: envelope.expiresAt, } } async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const serialized = this.serializer.serialize(output) + + // Outputs containing blobs or streaming values cannot be stored, so they are ignored. + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + return + } + await this.ensureConnection() const tags = options?.tags ?? [] - const serialized = this.serializer.stringify(output) const tagVersions: Record = {} if (tags.length) { diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index 38c0da7f7..e9b317f20 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -1,4 +1,5 @@ import type { RuntimeCache } from '@vercel/functions' +import { RPCSerializer } from '@orpc/client' import { getCache } from '@vercel/functions' import { VercelCacheStore } from './vercel' @@ -26,14 +27,13 @@ describe('vercelCacheStore', () => { await expect(store.get('unknown')).resolves.toBeUndefined() }) - it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + it('preserves Date, Map, Set, and BigInt outputs', async () => { const store = createTestingStore() const output = { date: new Date('2026-01-02T03:04:05.678Z'), map: new Map([['a', 1]]), set: new Set([1, 2]), big: 123n, - nothing: undefined, } await store.set('k', output) @@ -41,12 +41,12 @@ describe('vercelCacheStore', () => { await expect(store.get('k')).resolves.toMatchObject({ output }) }) - it('rejects outputs containing blobs', async () => { + it('ignores outputs containing blobs', async () => { const store = createTestingStore() - await expect( - store.set('k', { file: new Blob(['x']) }), - ).rejects.toThrow('VercelCacheStore cannot cache outputs containing Blob or File values') + await store.set('k', { file: new Blob(['x']) }) + + await expect(store.get('k')).resolves.toBeUndefined() }) it('invalidates entries by any of their tags via expireTag', async () => { @@ -135,17 +135,16 @@ describe('vercelCacheStore', () => { it('supports a custom serializer', async () => { const cache = createMockedCache() - const serializer = { - stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), - parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), - } + const serializer = new RPCSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') const store = new VercelCacheStore({ cache, serializer }) await store.set('k', { a: 1 }) await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) - expect(serializer.stringify).toHaveBeenCalled() - expect(serializer.parse).toHaveBeenCalled() + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() }) }) }) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index d06af8210..2acb430ba 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -1,17 +1,15 @@ +import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' -import type { CacheOutputSerializer } from './output-serializer' -import { toArray } from '@orpc/shared' +import { RPCSerializer } from '@orpc/client' +import { isAsyncIteratorObject, toArray } from '@orpc/shared' import { getCache } from '@vercel/functions' -import { createRpcJsonOutputSerializer } from './output-serializer' - -export type VercelCacheStoreSerializer = CacheOutputSerializer interface VercelCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. */ - output: string + output: unknown tags: readonly string[] expiresAt?: number | undefined evictAt?: number | undefined @@ -28,26 +26,27 @@ export interface VercelCacheStoreOptions { /** * Serializer for cached outputs. * - * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + * @default RPCSerializer */ - serializer?: VercelCacheStoreSerializer + serializer?: undefined | Public } /** * Cache store adapter for the Vercel Runtime Cache. Tags are expired * natively via `expireTag`, and entries are retained for `ttl + swr` * rounded up to whole seconds. Outside Vercel, the default `getCache()` - * falls back to an in-memory cache. + * falls back to an in-memory cache. Outputs containing Blob or File + * values are ignored and never stored. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class VercelCacheStore implements CacheStore { private readonly cache: RuntimeCache - private readonly serializer: VercelCacheStoreSerializer + private readonly serializer: Public constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() - this.serializer = options.serializer ?? createRpcJsonOutputSerializer('VercelCacheStore') + this.serializer = options.serializer ?? new RPCSerializer() } async get(key: string): Promise { @@ -63,20 +62,27 @@ export class VercelCacheStore implements CacheStore { } return { - output: this.serializer.parse(envelope.output), + output: this.serializer.deserialize(envelope.output as any), tags: envelope.tags, expiresAt: envelope.expiresAt, } } async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const serialized = this.serializer.serialize(output) + + // Outputs containing blobs or streaming values cannot be stored, so they are ignored. + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + return + } + const tags = options?.tags ?? [] const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined const evictAt = retention !== undefined ? Date.now() + retention : undefined const envelope: VercelCacheStoreEnvelope = { - output: this.serializer.stringify(output), + output: serialized, tags, expiresAt, evictAt, diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index 86bde1da3..3c5daeee8 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -1,4 +1,3 @@ -export * from './adapters/output-serializer' export * from './handler-plugin' export * from './middleware' export * from './types' diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index ffd54ac7b..1f0971a83 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -1,4 +1,5 @@ import type { KVCacheStoreOptions } from './kv-cache' +import { RPCSerializer } from '@orpc/client' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' import { KVCacheStore } from './kv-cache' @@ -29,14 +30,13 @@ describe('kvCacheStore', () => { await expect(store.get('unknown')).resolves.toBeUndefined() }) - it('preserves Date, Map, Set, BigInt, and undefined outputs', async () => { + it('preserves Date, Map, Set, and BigInt outputs', async () => { const { store } = createTestingStore() const output = { date: new Date('2026-01-02T03:04:05.678Z'), map: new Map([['a', 1]]), set: new Set([1, 2]), big: 123n, - nothing: undefined, } await store.set('k', output) @@ -44,26 +44,25 @@ describe('kvCacheStore', () => { await expect(store.get('k')).resolves.toMatchObject({ output }) }) - it('rejects outputs containing blobs', async () => { + it('ignores outputs containing blobs', async () => { const { store } = createTestingStore() - await expect( - store.set('k', { file: new Blob(['x']) }), - ).rejects.toThrow('KVCacheStore cannot cache outputs containing Blob or File values') + await store.set('k', { file: new Blob(['x']) }) + + await expect(store.get('k')).resolves.toBeUndefined() }) it('supports a custom serializer', async () => { - const serializer = { - stringify: vi.fn((data: unknown) => `custom:${JSON.stringify(data)}`), - parse: vi.fn((text: string) => JSON.parse(text.slice('custom:'.length))), - } + const serializer = new RPCSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') const { store } = createTestingStore({ serializer }) await store.set('k', { a: 1 }) await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) - expect(serializer.stringify).toHaveBeenCalled() - expect(serializer.parse).toHaveBeenCalled() + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() }) it('invalidates entries by any of their tags', async () => { @@ -105,7 +104,7 @@ describe('kvCacheStore', () => { // Craft envelopes directly so the test does not have to wait for real time to pass. const envelope = (expiresAt: number, evictAt: number) => JSON.stringify({ - output: JSON.stringify({ json: 'v' }), + output: { json: 'v' }, tags: [], tagTokens: {}, expiresAt, diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index 9faaa3fd4..3962110a7 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -1,12 +1,13 @@ -import type { CacheEntry, CacheOutputSerializer, CacheSetOptions, CacheStore } from '@orpc/cache' -import { createRpcJsonOutputSerializer } from '@orpc/cache' -import { stringifyJSON, toArray } from '@orpc/shared' +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' +import type { Public } from '@orpc/shared' +import { RPCSerializer } from '@orpc/client' +import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface KVCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. */ - output: string + output: unknown tags: readonly string[] /** * Tag tokens snapshotted at set time. A tag's live token changes on every @@ -34,9 +35,9 @@ export interface KVCacheStoreOptions { /** * Serializer for cached outputs. * - * @default an RPCJsonSerializer-backed serializer preserving Date, BigInt, Set, Map, URL, RegExp, NaN, and undefined values + * @default RPCSerializer */ - serializer?: CacheOutputSerializer + serializer?: undefined | Public } /** @@ -44,7 +45,8 @@ export interface KVCacheStoreOptions { * Tags are tracked with random tokens rewritten on every revalidation, so no * atomic operations are required. Entries are retained for `ttl + swr` via * `expirationTtl`, clamped to KV's 60 second minimum; the exact bounds are - * still enforced on `get`. + * still enforced on `get`. Outputs containing Blob or File values are + * ignored and never stored. * * @remarks * **Note**: KV is [eventually consistent](https://developers.cloudflare.com/kv/concepts/how-kv-works/#consistency): @@ -56,12 +58,12 @@ export interface KVCacheStoreOptions { export class KVCacheStore implements CacheStore { private readonly kv: KVNamespace private readonly prefix: string - private readonly serializer: CacheOutputSerializer + private readonly serializer: Public constructor(options: KVCacheStoreOptions) { this.kv = options.kv this.prefix = options.prefix ?? '' - this.serializer = options.serializer ?? createRpcJsonOutputSerializer('KVCacheStore') + this.serializer = options.serializer ?? new RPCSerializer() } async get(key: string): Promise { @@ -90,15 +92,21 @@ export class KVCacheStore implements CacheStore { } return { - output: this.serializer.parse(envelope.output), + output: this.serializer.deserialize(envelope.output as any), tags: envelope.tags, expiresAt: envelope.expiresAt, } } async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + const serialized = this.serializer.serialize(output) + + // Outputs containing blobs or streaming values cannot be stored, so they are ignored. + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + return + } + const tags = options?.tags ?? [] - const serialized = this.serializer.stringify(output) const tagTokens: Record = {} if (tags.length) { From 9329af78ee828f9752a38275e1e38f1506512b17 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:22:56 +0700 Subject: [PATCH 09/18] refactor(cache): encode keys inside stores with sorted key material --- packages/cache/src/adapters/memory.test.ts | 18 +++++++++ packages/cache/src/adapters/memory.ts | 44 ++++++++++++++++++---- packages/cache/src/adapters/redis.ts | 24 +++++++++--- packages/cache/src/adapters/vercel.ts | 26 ++++++++++--- packages/cache/src/handler-plugin.ts | 2 +- packages/cache/src/middleware.test.ts | 23 +++-------- packages/cache/src/middleware.ts | 19 ++-------- packages/cache/src/types.ts | 6 ++- packages/cloudflare/src/kv-cache.ts | 24 +++++++++--- packages/shared/src/object.test.ts | 21 ++++++++++- packages/shared/src/object.ts | 21 +++++++++++ 11 files changed, 168 insertions(+), 60 deletions(-) diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts index 7a7a44320..4df5a4a68 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -26,6 +26,24 @@ describe('memoryCacheStore', () => { await expect(store.get('unknown')).resolves.toBeUndefined() }) + it('encodes structurally equal non-string keys to the same entry', async () => { + const store = new MemoryCacheStore() + + await store.set([['planet', 'find'], { b: 2, a: 1 }], 'v') + + await expect(store.get([['planet', 'find'], { a: 1, b: 2 }])).resolves.toMatchObject({ output: 'v' }) + await expect(store.get([['planet', 'find'], { a: 1, b: 3 }])).resolves.toBeUndefined() + await expect(store.get([['planet', 'list'], { a: 1, b: 2 }])).resolves.toBeUndefined() + }) + + it('throws for keys containing blobs', async () => { + const store = new MemoryCacheStore() + + await expect(store.get({ file: new Blob(['x']) })).rejects.toThrow( + 'Cache keys must be serializable to JSON, provide an explicit string key instead', + ) + }) + it('returns fresh entries with a future expiresAt, then evicts at ttl without swr', async () => { const store = new MemoryCacheStore() diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index 030851b63..225187d05 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,5 +1,7 @@ +import type { Public } from '@orpc/shared' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' -import { toArray } from '@orpc/shared' +import { RPCSerializer } from '@orpc/client' +import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface MemoryCacheStoreEntry { output: unknown @@ -12,6 +14,15 @@ interface MemoryCacheStoreEntry { evictAt: number | undefined } +export interface MemoryCacheStoreOptions { + /** + * Serializer used to encode non-string keys. + * + * @default RPCSerializer + */ + serializer?: undefined | Public +} + /** * In-memory cache store with tag-based invalidation, intended for * development, testing, and single-instance deployments. Expired and @@ -22,16 +33,21 @@ interface MemoryCacheStoreEntry { export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagVersions = new Map() + private readonly serializer: Public - async get(key: string): Promise { - const entry = this.entries.get(key) + constructor(options: MemoryCacheStoreOptions = {}) { + this.serializer = options.serializer ?? new RPCSerializer() + } + + async get(key: unknown): Promise { + const entry = this.entries.get(this.encodeKey(key)) if (!entry) { return undefined } if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { - this.entries.delete(key) + this.entries.delete(this.encodeKey(key)) return undefined } @@ -40,7 +56,7 @@ export class MemoryCacheStore implements CacheStore { ) if (revalidated) { - this.entries.delete(key) + this.entries.delete(this.encodeKey(key)) return undefined } @@ -51,12 +67,12 @@ export class MemoryCacheStore implements CacheStore { } } - async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { const tags = options?.tags ?? [] const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined - this.entries.set(key, { + this.entries.set(this.encodeKey(key), { output, tags, tagVersions: tags.map(tag => this.tagVersions.get(tag) ?? 0), @@ -70,4 +86,18 @@ export class MemoryCacheStore implements CacheStore { this.tagVersions.set(t, (this.tagVersions.get(t) ?? 0) + 1) } } + + private encodeKey(key: unknown): string { + if (typeof key === 'string') { + return key + } + + const serialized = this.serializer.serialize(deepSortKeys(key)) + + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') + } + + return `${stringifyJSON(serialized)}` + } } diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index f11a3c270..40103c766 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -2,7 +2,7 @@ import type { Public } from '@orpc/shared' import type { RedisClientType } from 'redis' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' import { RPCSerializer } from '@orpc/client' -import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface RedisCacheStoreEnvelope { /** @@ -58,7 +58,7 @@ export class RedisCacheStore implements CacheStore { this.serializer = options.serializer ?? new RPCSerializer() } - async get(key: string): Promise { + async get(key: unknown): Promise { await this.ensureConnection() const raw = await this.redis.get(this.entryKey(key)) @@ -89,7 +89,7 @@ export class RedisCacheStore implements CacheStore { } } - async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { const serialized = this.serializer.serialize(output) // Outputs containing blobs or streaming values cannot be stored, so they are ignored. @@ -147,8 +147,22 @@ export class RedisCacheStore implements CacheStore { await multi.exec() } - private entryKey(key: string): string { - return `${this.prefix}entry:${key}` + private encodeKey(key: unknown): string { + if (typeof key === 'string') { + return key + } + + const serialized = this.serializer.serialize(deepSortKeys(key)) + + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') + } + + return `${stringifyJSON(serialized)}` + } + + private entryKey(key: unknown): string { + return `${this.prefix}entry:${this.encodeKey(key)}` } private tagKey(tag: string): string { diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 2acb430ba..301bbc78a 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -2,7 +2,7 @@ import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' import { RPCSerializer } from '@orpc/client' -import { isAsyncIteratorObject, toArray } from '@orpc/shared' +import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' import { getCache } from '@vercel/functions' interface VercelCacheStoreEnvelope { @@ -49,15 +49,15 @@ export class VercelCacheStore implements CacheStore { this.serializer = options.serializer ?? new RPCSerializer() } - async get(key: string): Promise { - const envelope = await this.cache.get(key) as VercelCacheStoreEnvelope | null | undefined + async get(key: unknown): Promise { + const envelope = await this.cache.get(this.encodeKey(key)) as VercelCacheStoreEnvelope | null | undefined if (envelope == null) { return undefined } if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { - await this.cache.delete(key) + await this.cache.delete(this.encodeKey(key)) return undefined } @@ -68,7 +68,7 @@ export class VercelCacheStore implements CacheStore { } } - async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { const serialized = this.serializer.serialize(output) // Outputs containing blobs or streaming values cannot be stored, so they are ignored. @@ -88,12 +88,26 @@ export class VercelCacheStore implements CacheStore { evictAt, } - await this.cache.set(key, envelope, { + await this.cache.set(this.encodeKey(key), envelope, { ...(tags.length ? { tags: [...tags] } : {}), ...(retention !== undefined ? { ttl: Math.ceil(retention / 1000) } : {}), }) } + private encodeKey(key: unknown): string { + if (typeof key === 'string') { + return key + } + + const serialized = this.serializer.serialize(deepSortKeys(key)) + + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') + } + + return `${stringifyJSON(serialized)}` + } + async revalidateTag(tag: string | readonly string[]): Promise { const tags = toArray(tag) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index b16a02e6f..e3740b9c1 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -12,7 +12,7 @@ export interface CacheHandlerPluginContext { * `ttl` carries the remaining freshness in milliseconds on hits and the * resolved fresh lifetime on stores. */ - caches: { procedure: AnyProcedure, path: string[], hit: boolean, stale: boolean, key: string, tags: readonly string[], ttl?: number | undefined, swr?: number | undefined }[] + caches: { procedure: AnyProcedure, path: string[], hit: boolean, stale: boolean, key: unknown, tags: readonly string[], ttl?: number | undefined, swr?: number | undefined }[] /** * The tag revalidations committed during this request. diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index 72f521827..127629085 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -41,10 +41,10 @@ describe('cache', () => { await call(procedure, { id: 1 }, { context: { cache: store }, path: ['user', 'find'] }) const keys = store.get.mock.calls.map(([key]) => key) - expect(keys.every(key => typeof key === 'string')).toBe(true) - expect(keys[0]).toBe(keys[1]) // same path + input - expect(keys[0]).not.toBe(keys[2]) // different input - expect(keys[0]).not.toBe(keys[3]) // different path + expect(keys[0]).toEqual([['planet', 'find'], { id: 1 }]) // the procedure path and input + expect(keys[0]).toEqual(keys[1]) // same path + input + expect(keys[0]).not.toEqual(keys[2]) // different input + expect(keys[0]).not.toEqual(keys[3]) // different path }) it('derives the key from non-string key material, and uses string keys verbatim', async () => { @@ -61,7 +61,7 @@ describe('cache', () => { await call(verbatim, undefined, { context: { cache: store } }) const keys = store.get.mock.calls.map(([key]) => key) - expect(keys[0]).toBe(keys[1]) // same material despite different inputs + expect(keys[0]).toEqual(keys[1]) // same material despite different inputs expect(keys[2]).toBe('k') }) @@ -80,18 +80,7 @@ describe('cache', () => { // The middleware only validated `id` at its position, but the key still // covers the full input, so different pages never share an entry. const keys = store.get.mock.calls.map(([key]) => key) - expect(keys[0]).not.toBe(keys[1]) - }) - - it('rejects default keys for inputs containing blobs', async () => { - const store = createStore() - const procedure = os.$context().input(type()).use(cache()).handler(() => 'ok') - - await expect( - call(procedure, { file: new Blob(['x']) }, { context: { cache: store } }), - ).rejects.toThrow('Cache key material must not contain Blob or File values') - - expect(store.get).not.toHaveBeenCalled() + expect(keys[0]).not.toEqual(keys[1]) }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index ff7e581a5..e52aa0577 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -2,8 +2,7 @@ import type { Context, Middleware, MiddlewareOptions } from '@orpc/server' import type { Promisable, Value } from '@orpc/shared' import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext } from './types' -import { RPCJsonSerializer } from '@orpc/client' -import { isAsyncIteratorObject, stringifyJSON, toArray, value } from '@orpc/shared' +import { isAsyncIteratorObject, toArray, value } from '@orpc/shared' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' /** @@ -20,7 +19,7 @@ export interface CacheMiddlewareOptions< /** * The key identifying the cache entry, or any serializable value to derive * it from. Strings are used verbatim, while any other value is combined - * with the procedure path and encoded into a key. + * with the procedure path and encoded by the store. * * @default the procedure path and input */ @@ -83,7 +82,7 @@ export function cache< return middlewareOptions.next() } - const key = typeof keyMaterial === 'string' ? keyMaterial : encodeCacheKey(middlewareOptions.path, keyMaterial) + const key = typeof keyMaterial === 'string' ? keyMaterial : [middlewareOptions.path, keyMaterial] const { cache: store, waitUntil } = middlewareOptions.context as CacheContext const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] @@ -182,15 +181,3 @@ export function revalidate< function isUncacheableOutput(output: unknown): boolean { return isAsyncIteratorObject(output) || output instanceof ReadableStream } - -const cacheKeySerializer = new RPCJsonSerializer() - -function encodeCacheKey(path: readonly string[], material: unknown): string { - const { json, meta, blobs } = cacheKeySerializer.serialize(material) - - if (blobs?.length) { - throw new TypeError('Cache key material must not contain Blob or File values; provide an explicit string key instead') - } - - return stringifyJSON({ path, json, meta }) -} diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index ff75cfb96..dc096eef3 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -62,13 +62,15 @@ export interface CacheStore { /** * Resolves the entry stored under `key`, or `undefined` on miss/evicted/revalidated. * Stale entries (past `expiresAt` but within the stale-while-revalidate window) are returned. + * Keys may be any serializable value; implementations encode them stably, + * so structurally equal keys resolve the same entry. */ - get(key: string): Promise + get(key: unknown): Promise /** * Stores `output` under `key`, replacing any previous entry. */ - set(key: string, output: unknown, options?: CacheSetOptions): Promise + set(key: unknown, output: unknown, options?: CacheSetOptions): Promise /** * Invalidates every entry associated with one or many tags. diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index 3962110a7..84e8468ac 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -1,7 +1,7 @@ import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' import type { Public } from '@orpc/shared' import { RPCSerializer } from '@orpc/client' -import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface KVCacheStoreEnvelope { /** @@ -66,7 +66,7 @@ export class KVCacheStore implements CacheStore { this.serializer = options.serializer ?? new RPCSerializer() } - async get(key: string): Promise { + async get(key: unknown): Promise { const envelope = await this.kv.get(this.entryKey(key), 'json') if (envelope === null) { @@ -98,7 +98,7 @@ export class KVCacheStore implements CacheStore { } } - async set(key: string, output: unknown, options?: CacheSetOptions): Promise { + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { const serialized = this.serializer.serialize(output) // Outputs containing blobs or streaming values cannot be stored, so they are ignored. @@ -146,8 +146,22 @@ export class KVCacheStore implements CacheStore { await Promise.all(tags.map(t => this.kv.put(this.tagKey(t), crypto.randomUUID()))) } - private entryKey(key: string): string { - return `${this.prefix}entry:${key}` + private encodeKey(key: unknown): string { + if (typeof key === 'string') { + return key + } + + const serialized = this.serializer.serialize(deepSortKeys(key)) + + if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { + throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') + } + + return `${stringifyJSON(serialized)}` + } + + private entryKey(key: unknown): string { + return `${this.prefix}entry:${this.encodeKey(key)}` } private tagKey(tag: string): string { diff --git a/packages/shared/src/object.test.ts b/packages/shared/src/object.test.ts index e624d2fcc..ca78b738e 100644 --- a/packages/shared/src/object.test.ts +++ b/packages/shared/src/object.test.ts @@ -1,7 +1,7 @@ import * as a from 'arktype' import * as v from 'valibot' import z from 'zod' -import { bindMethods, clone, findDeepMatches, get, getConstructor, getConstructors, getOwn, isPlainObject, isPropertyKey, mergeTwoLevels, NullProtoObj, omit, set } from './object' +import { bindMethods, clone, deepSortKeys, findDeepMatches, get, getConstructor, getConstructors, getOwn, isPlainObject, isPropertyKey, mergeTwoLevels, NullProtoObj, omit, set } from './object' it('findDeepMatches', () => { const { maps, values } = findDeepMatches(v => typeof v === 'string', { @@ -612,3 +612,22 @@ describe('bindMethods', () => { expect(methods.double()).toBe(246) }) }) + +describe('deepSortKeys', () => { + it('sorts plain object keys recursively, including inside arrays', () => { + expect(deepSortKeys({ b: 2, a: { d: 4, c: 3 }, list: [{ y: 1, x: 0 }] })) + .toEqual({ a: { c: 3, d: 4 }, b: 2, list: [{ x: 0, y: 1 }] }) + + expect(Object.keys(deepSortKeys({ b: 2, a: 1 }) as object)).toEqual(['a', 'b']) + }) + + it('returns non-plain values as-is', () => { + const date = new Date() + const map = new Map([['b', 2], ['a', 1]]) + + expect(deepSortKeys(date)).toBe(date) + expect(deepSortKeys(map)).toBe(map) + expect(deepSortKeys('str')).toBe('str') + expect(deepSortKeys(undefined)).toBeUndefined() + }) +}) diff --git a/packages/shared/src/object.ts b/packages/shared/src/object.ts index 44c186aba..70239c702 100644 --- a/packages/shared/src/object.ts +++ b/packages/shared/src/object.ts @@ -155,6 +155,27 @@ export function mergeTwoLevels(first: unknown, second: unknown): unknown { return result } +/** + * Recursively rebuilds plain objects with their keys in sorted order, so two + * structurally equal values produce the same serialized form. Arrays are + * mapped, anything else is returned as-is. + */ +export function deepSortKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(deepSortKeys) + } + + if (isPlainObject(value)) { + const sorted: Record = {} + for (const key of Object.keys(value).sort()) { + sorted[key] = deepSortKeys(value[key]) + } + return sorted + } + + return value +} + export function omit( obj: T, keys: readonly K[], From 4649b4cc9823c03b18a29ce2810e4058683470f8 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:29:19 +0700 Subject: [PATCH 10/18] refactor(shared): recurse into Maps and Sets in deepSortKeys --- packages/shared/src/object.test.ts | 15 +++++++++++++-- packages/shared/src/object.ts | 22 ++++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/object.test.ts b/packages/shared/src/object.test.ts index ca78b738e..8fb73045c 100644 --- a/packages/shared/src/object.test.ts +++ b/packages/shared/src/object.test.ts @@ -621,12 +621,23 @@ describe('deepSortKeys', () => { expect(Object.keys(deepSortKeys({ b: 2, a: 1 }) as object)).toEqual(['a', 'b']) }) + it('rebuilds Maps and Sets with sorted contents while keeping their order', () => { + const map = new Map([['b', { y: 2, x: 1 }], ['a', 1]]) + const set = new Set([{ y: 2, x: 1 }, 'b', 'a']) + + const sortedMap = deepSortKeys(map) as Map + expect([...sortedMap.keys()]).toEqual(['b', 'a']) // order preserved + expect(Object.keys(sortedMap.get('b') as object)).toEqual(['x', 'y']) + + const sortedSet = deepSortKeys(set) as Set + expect([...sortedSet]).toEqual([{ x: 1, y: 2 }, 'b', 'a']) + expect(Object.keys([...sortedSet][0] as object)).toEqual(['x', 'y']) + }) + it('returns non-plain values as-is', () => { const date = new Date() - const map = new Map([['b', 2], ['a', 1]]) expect(deepSortKeys(date)).toBe(date) - expect(deepSortKeys(map)).toBe(map) expect(deepSortKeys('str')).toBe('str') expect(deepSortKeys(undefined)).toBeUndefined() }) diff --git a/packages/shared/src/object.ts b/packages/shared/src/object.ts index 70239c702..6c5d94604 100644 --- a/packages/shared/src/object.ts +++ b/packages/shared/src/object.ts @@ -157,14 +157,32 @@ export function mergeTwoLevels(first: unknown, second: unknown): unknown { /** * Recursively rebuilds plain objects with their keys in sorted order, so two - * structurally equal values produce the same serialized form. Arrays are - * mapped, anything else is returned as-is. + * structurally equal values produce the same serialized form. Arrays, Maps, + * and Sets are rebuilt with the same treatment while keeping their element + * order, since element order is observable in JavaScript. Anything else is + * returned as-is. */ export function deepSortKeys(value: unknown): unknown { if (Array.isArray(value)) { return value.map(deepSortKeys) } + if (value instanceof Map) { + const result = new Map() + for (const [key, item] of value) { + result.set(deepSortKeys(key), deepSortKeys(item)) + } + return result + } + + if (value instanceof Set) { + const result = new Set() + for (const item of value) { + result.add(deepSortKeys(item)) + } + return result + } + if (isPlainObject(value)) { const sorted: Record = {} for (const key of Object.keys(value).sort()) { From b2b1ba5ed11d39867a04fb69bc8c93f2da610ced Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:41:36 +0700 Subject: [PATCH 11/18] refactor(cache): canonicalize keys after serialization via shared encodeCacheKey --- packages/cache/src/adapters/memory.test.ts | 11 +++--- packages/cache/src/adapters/memory.ts | 41 ++++------------------ packages/cache/src/adapters/redis.ts | 19 ++-------- packages/cache/src/adapters/vercel.ts | 23 +++--------- packages/cache/src/index.ts | 1 + packages/cache/src/utils.test.ts | 22 ++++++++++++ packages/cache/src/utils.ts | 24 +++++++++++++ packages/cloudflare/src/kv-cache.ts | 19 ++-------- packages/shared/src/object.test.ts | 15 ++------ packages/shared/src/object.ts | 22 ++---------- 10 files changed, 75 insertions(+), 122 deletions(-) create mode 100644 packages/cache/src/utils.test.ts create mode 100644 packages/cache/src/utils.ts diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts index 4df5a4a68..64b9b4f11 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -36,12 +36,15 @@ describe('memoryCacheStore', () => { await expect(store.get([['planet', 'list'], { a: 1, b: 2 }])).resolves.toBeUndefined() }) - it('throws for keys containing blobs', async () => { + it('encodes complex key values, ignoring unsupported ones like blobs', async () => { const store = new MemoryCacheStore() - await expect(store.get({ file: new Blob(['x']) })).rejects.toThrow( - 'Cache keys must be serializable to JSON, provide an explicit string key instead', - ) + await store.set({ date: new Date(1), big: 1n }, 'v') + await expect(store.get({ big: 1n, date: new Date(1) })).resolves.toMatchObject({ output: 'v' }) + await expect(store.get({ big: 2n, date: new Date(1) })).resolves.toBeUndefined() + + await store.set({ file: new Blob(['a']), id: 1 }, 'blobbed') + await expect(store.get({ file: new Blob(['b']), id: 1 })).resolves.toMatchObject({ output: 'blobbed' }) }) it('returns fresh entries with a future expiresAt, then evicts at ttl without swr', async () => { diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index 225187d05..e2b1da2ae 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,7 +1,6 @@ -import type { Public } from '@orpc/shared' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' -import { RPCSerializer } from '@orpc/client' -import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { toArray } from '@orpc/shared' +import { encodeCacheKey } from '../utils' interface MemoryCacheStoreEntry { output: unknown @@ -14,15 +13,6 @@ interface MemoryCacheStoreEntry { evictAt: number | undefined } -export interface MemoryCacheStoreOptions { - /** - * Serializer used to encode non-string keys. - * - * @default RPCSerializer - */ - serializer?: undefined | Public -} - /** * In-memory cache store with tag-based invalidation, intended for * development, testing, and single-instance deployments. Expired and @@ -33,21 +23,16 @@ export interface MemoryCacheStoreOptions { export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagVersions = new Map() - private readonly serializer: Public - - constructor(options: MemoryCacheStoreOptions = {}) { - this.serializer = options.serializer ?? new RPCSerializer() - } async get(key: unknown): Promise { - const entry = this.entries.get(this.encodeKey(key)) + const entry = this.entries.get(encodeCacheKey(key)) if (!entry) { return undefined } if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { - this.entries.delete(this.encodeKey(key)) + this.entries.delete(encodeCacheKey(key)) return undefined } @@ -56,7 +41,7 @@ export class MemoryCacheStore implements CacheStore { ) if (revalidated) { - this.entries.delete(this.encodeKey(key)) + this.entries.delete(encodeCacheKey(key)) return undefined } @@ -72,7 +57,7 @@ export class MemoryCacheStore implements CacheStore { const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined - this.entries.set(this.encodeKey(key), { + this.entries.set(encodeCacheKey(key), { output, tags, tagVersions: tags.map(tag => this.tagVersions.get(tag) ?? 0), @@ -86,18 +71,4 @@ export class MemoryCacheStore implements CacheStore { this.tagVersions.set(t, (this.tagVersions.get(t) ?? 0) + 1) } } - - private encodeKey(key: unknown): string { - if (typeof key === 'string') { - return key - } - - const serialized = this.serializer.serialize(deepSortKeys(key)) - - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') - } - - return `${stringifyJSON(serialized)}` - } } diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index 40103c766..b42df751d 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -2,7 +2,8 @@ import type { Public } from '@orpc/shared' import type { RedisClientType } from 'redis' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' import { RPCSerializer } from '@orpc/client' -import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { encodeCacheKey } from '../utils' interface RedisCacheStoreEnvelope { /** @@ -147,22 +148,8 @@ export class RedisCacheStore implements CacheStore { await multi.exec() } - private encodeKey(key: unknown): string { - if (typeof key === 'string') { - return key - } - - const serialized = this.serializer.serialize(deepSortKeys(key)) - - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') - } - - return `${stringifyJSON(serialized)}` - } - private entryKey(key: unknown): string { - return `${this.prefix}entry:${this.encodeKey(key)}` + return `${this.prefix}entry:${encodeCacheKey(key)}` } private tagKey(tag: string): string { diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 301bbc78a..fb5c4f551 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -2,8 +2,9 @@ import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' import { RPCSerializer } from '@orpc/client' -import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { isAsyncIteratorObject, toArray } from '@orpc/shared' import { getCache } from '@vercel/functions' +import { encodeCacheKey } from '../utils' interface VercelCacheStoreEnvelope { /** @@ -50,14 +51,14 @@ export class VercelCacheStore implements CacheStore { } async get(key: unknown): Promise { - const envelope = await this.cache.get(this.encodeKey(key)) as VercelCacheStoreEnvelope | null | undefined + const envelope = await this.cache.get(encodeCacheKey(key)) as VercelCacheStoreEnvelope | null | undefined if (envelope == null) { return undefined } if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { - await this.cache.delete(this.encodeKey(key)) + await this.cache.delete(encodeCacheKey(key)) return undefined } @@ -88,26 +89,12 @@ export class VercelCacheStore implements CacheStore { evictAt, } - await this.cache.set(this.encodeKey(key), envelope, { + await this.cache.set(encodeCacheKey(key), envelope, { ...(tags.length ? { tags: [...tags] } : {}), ...(retention !== undefined ? { ttl: Math.ceil(retention / 1000) } : {}), }) } - private encodeKey(key: unknown): string { - if (typeof key === 'string') { - return key - } - - const serialized = this.serializer.serialize(deepSortKeys(key)) - - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') - } - - return `${stringifyJSON(serialized)}` - } - async revalidateTag(tag: string | readonly string[]): Promise { const tags = toArray(tag) diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts index 3c5daeee8..795670487 100644 --- a/packages/cache/src/index.ts +++ b/packages/cache/src/index.ts @@ -1,3 +1,4 @@ export * from './handler-plugin' export * from './middleware' export * from './types' +export * from './utils' diff --git a/packages/cache/src/utils.test.ts b/packages/cache/src/utils.test.ts new file mode 100644 index 000000000..f1aa1c38a --- /dev/null +++ b/packages/cache/src/utils.test.ts @@ -0,0 +1,22 @@ +import { encodeCacheKey } from './utils' + +describe('encodeCacheKey', () => { + it('uses string keys verbatim', () => { + expect(encodeCacheKey('planet:1')).toBe('planet:1') + }) + + it('encodes structurally equal keys identically, regardless of property order', () => { + expect(encodeCacheKey([['planet', 'find'], { b: 2, a: 1 }])) + .toBe(encodeCacheKey([['planet', 'find'], { a: 1, b: 2 }])) + + expect(encodeCacheKey({ date: new Date(1), big: 1n })) + .toBe(encodeCacheKey({ big: 1n, date: new Date(1) })) + + expect(encodeCacheKey({ big: 1n })).not.toBe(encodeCacheKey({ big: 2n })) + }) + + it('ignores unsupported values like blobs', () => { + expect(encodeCacheKey({ file: new Blob(['a']), id: 1 })) + .toBe(encodeCacheKey({ file: new Blob(['b']), id: 1 })) + }) +}) diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts new file mode 100644 index 000000000..c44046e67 --- /dev/null +++ b/packages/cache/src/utils.ts @@ -0,0 +1,24 @@ +import type { Public } from '@orpc/shared' +import { RPCJsonSerializer } from '@orpc/client' +import { deepSortKeys, stringifyJSON } from '@orpc/shared' + +const defaultKeySerializer = new RPCJsonSerializer() + +/** + * Encodes a cache key into a stable string: strings are used verbatim, while + * any other value is serialized with the RPC JSON serializer first, so + * complex values become plain JSON, then canonicalized by sorting object + * keys and meta entries. Structurally equal keys always encode identically, + * and unsupported values like blobs are ignored. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function encodeCacheKey(key: unknown, serializer: Public = defaultKeySerializer): string { + if (typeof key === 'string') { + return key + } + + const { json, meta } = serializer.serialize(key) + + return `${stringifyJSON(deepSortKeys([json, meta?.map(entry => stringifyJSON(entry)).sort()]))}` +} diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index 84e8468ac..dff5f3fec 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -1,7 +1,8 @@ import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' import type { Public } from '@orpc/shared' +import { encodeCacheKey } from '@orpc/cache' import { RPCSerializer } from '@orpc/client' -import { deepSortKeys, isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface KVCacheStoreEnvelope { /** @@ -146,22 +147,8 @@ export class KVCacheStore implements CacheStore { await Promise.all(tags.map(t => this.kv.put(this.tagKey(t), crypto.randomUUID()))) } - private encodeKey(key: unknown): string { - if (typeof key === 'string') { - return key - } - - const serialized = this.serializer.serialize(deepSortKeys(key)) - - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - throw new TypeError('Cache keys must be serializable to JSON, provide an explicit string key instead') - } - - return `${stringifyJSON(serialized)}` - } - private entryKey(key: unknown): string { - return `${this.prefix}entry:${this.encodeKey(key)}` + return `${this.prefix}entry:${encodeCacheKey(key)}` } private tagKey(tag: string): string { diff --git a/packages/shared/src/object.test.ts b/packages/shared/src/object.test.ts index 8fb73045c..ca78b738e 100644 --- a/packages/shared/src/object.test.ts +++ b/packages/shared/src/object.test.ts @@ -621,23 +621,12 @@ describe('deepSortKeys', () => { expect(Object.keys(deepSortKeys({ b: 2, a: 1 }) as object)).toEqual(['a', 'b']) }) - it('rebuilds Maps and Sets with sorted contents while keeping their order', () => { - const map = new Map([['b', { y: 2, x: 1 }], ['a', 1]]) - const set = new Set([{ y: 2, x: 1 }, 'b', 'a']) - - const sortedMap = deepSortKeys(map) as Map - expect([...sortedMap.keys()]).toEqual(['b', 'a']) // order preserved - expect(Object.keys(sortedMap.get('b') as object)).toEqual(['x', 'y']) - - const sortedSet = deepSortKeys(set) as Set - expect([...sortedSet]).toEqual([{ x: 1, y: 2 }, 'b', 'a']) - expect(Object.keys([...sortedSet][0] as object)).toEqual(['x', 'y']) - }) - it('returns non-plain values as-is', () => { const date = new Date() + const map = new Map([['b', 2], ['a', 1]]) expect(deepSortKeys(date)).toBe(date) + expect(deepSortKeys(map)).toBe(map) expect(deepSortKeys('str')).toBe('str') expect(deepSortKeys(undefined)).toBeUndefined() }) diff --git a/packages/shared/src/object.ts b/packages/shared/src/object.ts index 6c5d94604..70239c702 100644 --- a/packages/shared/src/object.ts +++ b/packages/shared/src/object.ts @@ -157,32 +157,14 @@ export function mergeTwoLevels(first: unknown, second: unknown): unknown { /** * Recursively rebuilds plain objects with their keys in sorted order, so two - * structurally equal values produce the same serialized form. Arrays, Maps, - * and Sets are rebuilt with the same treatment while keeping their element - * order, since element order is observable in JavaScript. Anything else is - * returned as-is. + * structurally equal values produce the same serialized form. Arrays are + * mapped, anything else is returned as-is. */ export function deepSortKeys(value: unknown): unknown { if (Array.isArray(value)) { return value.map(deepSortKeys) } - if (value instanceof Map) { - const result = new Map() - for (const [key, item] of value) { - result.set(deepSortKeys(key), deepSortKeys(item)) - } - return result - } - - if (value instanceof Set) { - const result = new Set() - for (const item of value) { - result.add(deepSortKeys(item)) - } - return result - } - if (isPlainObject(value)) { const sorted: Record = {} for (const key of Object.keys(value).sort()) { From 87232ffc6bfbabc811927cf21031f97c1899aecc Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:44:39 +0700 Subject: [PATCH 12/18] refactor(cache): construct the default key serializer per call --- packages/cache/src/utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index c44046e67..45944d6ca 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -2,8 +2,6 @@ import type { Public } from '@orpc/shared' import { RPCJsonSerializer } from '@orpc/client' import { deepSortKeys, stringifyJSON } from '@orpc/shared' -const defaultKeySerializer = new RPCJsonSerializer() - /** * Encodes a cache key into a stable string: strings are used verbatim, while * any other value is serialized with the RPC JSON serializer first, so @@ -13,7 +11,7 @@ const defaultKeySerializer = new RPCJsonSerializer() * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export function encodeCacheKey(key: unknown, serializer: Public = defaultKeySerializer): string { +export function encodeCacheKey(key: unknown, serializer: Public = new RPCJsonSerializer()): string { if (typeof key === 'string') { return key } From 99ff5a3b4a191a9b123ba7c753a89ec61b40c094 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:48:38 +0700 Subject: [PATCH 13/18] feat(cache): allow configuring the memory store key serializer --- apps/content/docs/helpers/cache.mdx | 9 +++++++- packages/cache/src/adapters/memory.test.ts | 12 +++++++++++ packages/cache/src/adapters/memory.ts | 24 ++++++++++++++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 60c5bfb95..33429e18c 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -47,7 +47,14 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w ```ts memory import { MemoryCacheStore } from '@orpc/cache/memory' -const store = new MemoryCacheStore() +const store = new MemoryCacheStore({ + /** + * Serializer used to encode non-string keys. + * + * @default RPCJsonSerializer + */ + serializer: undefined, +}) ``` ```ts redis diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts index 64b9b4f11..3cf7b8b07 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -1,3 +1,4 @@ +import { RPCJsonSerializer } from '@orpc/client' import { MemoryCacheStore } from './memory' describe('memoryCacheStore', () => { @@ -36,6 +37,17 @@ describe('memoryCacheStore', () => { await expect(store.get([['planet', 'list'], { a: 1, b: 2 }])).resolves.toBeUndefined() }) + it('supports a custom key serializer', async () => { + const serializer = new RPCJsonSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const store = new MemoryCacheStore({ serializer }) + + await store.set({ id: 1 }, 'v') + + await expect(store.get({ id: 1 })).resolves.toMatchObject({ output: 'v' }) + expect(serializeSpy).toHaveBeenCalled() + }) + it('encodes complex key values, ignoring unsupported ones like blobs', async () => { const store = new MemoryCacheStore() diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index e2b1da2ae..f27148172 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,7 +1,18 @@ +import type { RPCJsonSerializer } from '@orpc/client' +import type { Public } from '@orpc/shared' import type { CacheEntry, CacheSetOptions, CacheStore } from '../types' import { toArray } from '@orpc/shared' import { encodeCacheKey } from '../utils' +export interface MemoryCacheStoreOptions { + /** + * Serializer used to encode non-string keys. + * + * @default RPCJsonSerializer + */ + serializer?: undefined | Public +} + interface MemoryCacheStoreEntry { output: unknown tags: readonly string[] @@ -23,16 +34,21 @@ interface MemoryCacheStoreEntry { export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagVersions = new Map() + private readonly serializer: Public | undefined + + constructor(options: MemoryCacheStoreOptions = {}) { + this.serializer = options.serializer + } async get(key: unknown): Promise { - const entry = this.entries.get(encodeCacheKey(key)) + const entry = this.entries.get(encodeCacheKey(key, this.serializer)) if (!entry) { return undefined } if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { - this.entries.delete(encodeCacheKey(key)) + this.entries.delete(encodeCacheKey(key, this.serializer)) return undefined } @@ -41,7 +57,7 @@ export class MemoryCacheStore implements CacheStore { ) if (revalidated) { - this.entries.delete(encodeCacheKey(key)) + this.entries.delete(encodeCacheKey(key, this.serializer)) return undefined } @@ -57,7 +73,7 @@ export class MemoryCacheStore implements CacheStore { const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined - this.entries.set(encodeCacheKey(key), { + this.entries.set(encodeCacheKey(key, this.serializer), { output, tags, tagVersions: tags.map(tag => this.tagVersions.get(tag) ?? 0), From b70ec44cb4eeafdf0b38b85914da4cd2fc107b5e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 10:57:04 +0700 Subject: [PATCH 14/18] docs(cache): document key encoding in the adapters section --- apps/content/docs/helpers/cache.mdx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 33429e18c..e732dadf5 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -34,13 +34,15 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w ## 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) | -| `KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | -| `WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | +| 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) | +| `KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | +| `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. From 1cca86b205d31afc9433f0e916685eb3cd620732 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 11:06:22 +0700 Subject: [PATCH 15/18] refactor(cache): make the handler plugin inert until headers are configured --- apps/content/docs/helpers/cache.mdx | 19 ++--- packages/cache/src/handler-plugin.test.ts | 47 ++++++------ packages/cache/src/handler-plugin.ts | 89 +++++++++++++---------- packages/cache/tests/e2e.test.ts | 4 +- packages/cloudflare/src/workers-cache.ts | 4 +- 5 files changed, 84 insertions(+), 79 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index e732dadf5..a2e77fee7 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -153,7 +153,7 @@ import { WorkersCacheStore } from '@orpc/cloudflare' export default { async fetch(request, env, ctx) { // Workers Caching caches whole responses in front of the Worker via the - // `httpCacheHeaders` option of the handler plugin; this store only purges + // `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 @@ -246,35 +246,28 @@ const updatePlanet = os ## Handler Plugin -The `CacheHandlerPlugin` reflects cache activity in HTTP response headers when used with [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware): +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 `%`, `,`, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Use `decodeCacheTagHeader` to parse a header back into tags. ```ts -import { CacheHandlerPlugin } from '@orpc/cache' +import { CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin } from '@orpc/cache' const handler = new RPCHandler(router, { plugins: [ new CacheHandlerPlugin({ - /** - * Also reflect the root cache check into `Cache-Control` and `Cache-Tag` - * headers on GET and HEAD responses, so response caches in front, such - * as CDNs or Cloudflare Workers Caching, can serve and purge whole - * responses. Headers already present are never overridden. - * - * @default false - */ - httpCacheHeaders: false, + headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER], }), ], }) ``` :::info[Response Caches in Front] -With `httpCacheHeaders` enabled, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `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. +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 `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 diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts index 8c44dde5c..c834ca848 100644 --- a/packages/cache/src/handler-plugin.test.ts +++ b/packages/cache/src/handler-plugin.test.ts @@ -18,7 +18,7 @@ describe('cacheHandlerPlugin', () => { const handler = new RPCHandler(procedure, { allowMethods: ['GET'], // tests below send GET requests plugins: [ - new CacheHandlerPlugin(), + new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), ], }) @@ -26,6 +26,23 @@ describe('cacheHandlerPlugin', () => { handlerFn.mockReset() }) + it('does nothing by default', async () => { + const defaultHandler = new RPCHandler(procedure, { + allowMethods: ['GET'], + plugins: [new CacheHandlerPlugin()], + }) + + handlerFn.mockImplementationOnce(({ context }) => { + expect(context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]).toBeUndefined() + }) + + const { response } = await defaultHandler.handle(new Request('http://localhost:3000')) + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) + expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + }) + it('reflects cache tags from the first check of the called procedure', async () => { handlerFn.mockImplementationOnce(({ context, path, procedure }) => { context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( @@ -155,7 +172,7 @@ describe('cacheHandlerPlugin', () => { const nestedHandler = new RPCHandler({ outer, inner }, { allowMethods: ['GET'], - plugins: [new CacheHandlerPlugin()], + plugins: [new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] })], }) const { response } = await nestedHandler.handle(new Request('http://localhost:3000/outer'), { @@ -167,13 +184,13 @@ describe('cacheHandlerPlugin', () => { }) }) -describe('cacheHandlerPlugin httpCacheHeaders', () => { +describe('cacheHandlerPlugin cache-control and cache-tag headers', () => { const handlerFn = vi.fn() const procedure = os.handler(handlerFn) const handler = new RPCHandler(procedure, { allowMethods: ['GET', 'POST'], plugins: [ - new CacheHandlerPlugin({ httpCacheHeaders: true }), + new CacheHandlerPlugin({ headers: ['cache-control', 'cache-tag'] }), ], }) @@ -190,7 +207,7 @@ describe('cacheHandlerPlugin httpCacheHeaders', () => { const { response } = await handler.handle(new Request('http://localhost:3000')) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets,a%2Cb') + expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) // only configured headers are set expect(response!.headers.get('cache-tag')).toBe('planets,a%2Cb') expect(response!.headers.get('cache-control')).toBe('public, s-maxage=2, stale-while-revalidate=1') }) @@ -221,7 +238,6 @@ describe('cacheHandlerPlugin httpCacheHeaders', () => { headers: { 'content-type': 'application/json' }, })) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets') expect(response!.headers.get('cache-tag')).toBe(null) expect(response!.headers.get('cache-control')).toBe(null) }) @@ -235,25 +251,6 @@ describe('cacheHandlerPlugin httpCacheHeaders', () => { const { response } = await handler.handle(new Request('http://localhost:3000')) - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('planets') - expect(response!.headers.get('cache-control')).toBe(null) - }) - - it('never emits HTTP caching headers by default', async () => { - const defaultHandler = new RPCHandler(procedure, { - allowMethods: ['GET'], - plugins: [new CacheHandlerPlugin()], - }) - - handlerFn.mockImplementationOnce(({ context, path, procedure }) => { - context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL].caches.push( - { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets'], ttl: 1500 }, - ) - }) - - const { response } = await defaultHandler.handle(new Request('http://localhost:3000')) - - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets') expect(response!.headers.get('cache-tag')).toBe(null) expect(response!.headers.get('cache-control')).toBe(null) }) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index e3740b9c1..a7f1bbf23 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -57,39 +57,55 @@ export function decodeCacheTagHeader(header: string): string[] { return header.split(',').filter(Boolean).map(tryDecodeURIComponent) } +/** + * The response headers the cache handler plugin can set. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export type CacheHandlerPluginHeader + = | typeof CACHE_TAG_HEADER + | typeof CACHE_TAG_INVALIDATION_HEADER + | 'cache-control' + | 'cache-tag' + export interface CacheHandlerPluginOptions { /** - * Also reflects the root cache check into standard HTTP caching headers on - * GET and HEAD responses: `Cache-Tag` with the same encoded tags, and - * `Cache-Control: public, s-maxage=...` (plus `stale-while-revalidate` when - * `swr` is set) derived from the check's freshness. Headers already present - * on the response are never overridden. This lets response caches in front, - * such as CDNs or Cloudflare Workers Caching, serve and purge whole responses. + * The response headers to set from the root procedure's cache activity; + * only listed headers are set. `orpc-cache-tag` carries the tags the + * response depends on and `orpc-cache-tag-invalidation` the tags + * revalidated by the request, for client-side revalidation. `cache-tag` + * and `cache-control` are their 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. * - * @default false + * @default [] */ - httpCacheHeaders?: boolean + headers?: readonly CacheHandlerPluginHeader[] } /** - * Reflects cache tags and revalidated tags into the `orpc-cache-tag` and - * `orpc-cache-tag-invalidation` response headers when used with the `cache` and - * `revalidate` middlewares. Only the first check belonging to the procedure - * the client called is reflected, so nested procedure calls never leak - * their tags into the response. + * Reflects the cache activity of the `cache` and `revalidate` middlewares + * into the configured response headers. Only the first check belonging to + * the procedure the client called is reflected, so nested procedure calls + * never leak their tags into the response. Does nothing until headers are + * configured. * * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} */ export class CacheHandlerPlugin implements StandardHandlerPlugin { name = '~cache' - private readonly httpCacheHeaders: boolean + private readonly headers: Set constructor(options: CacheHandlerPluginOptions = {}) { - this.httpCacheHeaders = options.httpCacheHeaders ?? false + this.headers = new Set(options.headers) } init(options: StandardHandlerOptions): StandardHandlerOptions { + if (!this.headers.size) { + return options + } + const interceptor: StandardHandlerInterceptor = async (interceptorOptions) => { const pluginContext: Exclude = { caches: [], revalidations: [] } @@ -109,39 +125,38 @@ export class CacheHandlerPlugin implements StandardHandlerPlu ) const method = interceptorOptions.request.method.toUpperCase() - const emitHttpHeaders = this.httpCacheHeaders && rootCache !== undefined && (method === 'GET' || method === 'HEAD') - - if (!rootCache?.tags.length && !rootRevalidation?.tags.length && !emitHttpHeaders) { - return response - } + const isHttpCacheable = rootCache !== undefined && (method === 'GET' || method === 'HEAD') const headers: StandardHeaders = { ...response.headers } + let changed = false - if (rootCache?.tags.length) { + if (this.headers.has(CACHE_TAG_HEADER) && rootCache?.tags.length) { headers[CACHE_TAG_HEADER] = encodeCacheTagHeader(rootCache.tags) + changed = true } - if (rootRevalidation?.tags.length) { + if (this.headers.has(CACHE_TAG_INVALIDATION_HEADER) && rootRevalidation?.tags.length) { headers[CACHE_TAG_INVALIDATION_HEADER] = encodeCacheTagHeader(rootRevalidation.tags) + changed = true + } + + if (this.headers.has('cache-tag') && isHttpCacheable && rootCache.tags.length && headers['cache-tag'] === undefined) { + headers['cache-tag'] = encodeCacheTagHeader(rootCache.tags) + changed = true } - if (emitHttpHeaders) { - if (headers['cache-tag'] === undefined && rootCache.tags.length) { - headers['cache-tag'] = encodeCacheTagHeader(rootCache.tags) - } - - if (headers['cache-control'] === undefined) { - /** - * Entries without a ttl stay valid until revalidated, so front caches - * hold them for a year and rely on tag purges. - */ - const sMaxAge = rootCache.ttl !== undefined ? Math.ceil(rootCache.ttl / 1000) : 31536000 - const staleWhileRevalidate = rootCache.swr !== undefined && rootCache.swr > 0 ? `, stale-while-revalidate=${Math.ceil(rootCache.swr / 1000)}` : '' - headers['cache-control'] = `public, s-maxage=${sMaxAge}${staleWhileRevalidate}` - } + if (this.headers.has('cache-control') && isHttpCacheable && headers['cache-control'] === undefined) { + /** + * Entries without a ttl stay valid until revalidated, so front caches + * hold them for a year and rely on tag purges. + */ + const sMaxAge = rootCache.ttl !== undefined ? Math.ceil(rootCache.ttl / 1000) : 31536000 + const staleWhileRevalidate = rootCache.swr !== undefined && rootCache.swr > 0 ? `, stale-while-revalidate=${Math.ceil(rootCache.swr / 1000)}` : '' + headers['cache-control'] = `public, s-maxage=${sMaxAge}${staleWhileRevalidate}` + changed = true } - return { ...response, headers } + return changed ? { ...response, headers } : response } return { diff --git a/packages/cache/tests/e2e.test.ts b/packages/cache/tests/e2e.test.ts index da13d8548..d42fb0281 100644 --- a/packages/cache/tests/e2e.test.ts +++ b/packages/cache/tests/e2e.test.ts @@ -2,7 +2,7 @@ import type { CacheContext } from '../src' import { os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { z } from 'zod' -import { cache, CacheHandlerPlugin, revalidate } from '../src' +import { cache, CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin, revalidate } from '../src' import { MemoryCacheStore } from '../src/adapters/memory' it('works', async () => { @@ -32,7 +32,7 @@ it('works', async () => { const handler = new RPCHandler(router, { plugins: [ - new CacheHandlerPlugin(), + new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), ], }) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index e734110ed..5b4c328d7 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -23,8 +23,8 @@ export interface WorkersCacheStoreOptions { /** * Purge-only cache store for Cloudflare Workers Caching. Responses are cached * in front of the Worker through `Cache-Control` and `Cache-Tag` headers (see - * the `CacheHandlerPlugin` `httpCacheHeaders` option), so `get` always misses - * and `set` stores nothing; `revalidateTag` purges the tags through Workers + * the `CacheHandlerPlugin` `headers` option), so `get` always misses and + * `set` stores nothing; `revalidateTag` purges the tags through Workers * Caching. * * @remarks From f95ba533668b8105cbb330b6ee4152170c2503da Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 11:14:44 +0700 Subject: [PATCH 16/18] fix(cache): percent-encode uppercase tag characters for case-insensitive caches --- apps/content/docs/helpers/cache.mdx | 2 +- packages/cache/src/handler-plugin.test.ts | 9 +++++++-- packages/cache/src/handler-plugin.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index a2e77fee7..73f90d535 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -252,7 +252,7 @@ The `CacheHandlerPlugin` reflects the cache activity of [Cache Middleware](#cach - `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 `%`, `,`, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Use `decodeCacheTagHeader` to parse a header back into tags. +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/cache' diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts index c834ca848..7e6dd5f5f 100644 --- a/packages/cache/src/handler-plugin.test.ts +++ b/packages/cache/src/handler-plugin.test.ts @@ -257,12 +257,17 @@ describe('cacheHandlerPlugin cache-control and cache-tag headers', () => { }) describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { - it('round-trips tags with commas, percents, and unicode', () => { - const tags = ['plain', 'a,b', '100%', 'tiαΊΏng việt', 'sp ace'] + it('round-trips tags with commas, percents, uppercase, and unicode', () => { + const tags = ['plain', 'a,b', '100%', 'CamelCase', 'tiαΊΏng việt', 'sp ace'] expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) }) + it('percent-encodes uppercase letters so case-insensitive caches keep tags distinct', () => { + expect(encodeCacheTagHeader(['Planets'])).toBe('%50lanets') + expect(encodeCacheTagHeader(['Planets'])).not.toBe(encodeCacheTagHeader(['planets'])) + }) + it('decodes empty headers to no tags', () => { expect(decodeCacheTagHeader('')).toEqual([]) }) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index a7f1bbf23..e6eedb872 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -38,14 +38,19 @@ export const CACHE_TAG_INVALIDATION_HEADER = 'orpc-cache-tag-invalidation' /** * Encodes cache tags into a header value: tags are joined with commas, and - * only `%`, `,`, and characters that cannot appear in a header value - * (whitespace, control characters, non-ASCII) are percent-encoded, so - * typical tags stay readable. + * only `%`, `,`, uppercase letters, and characters that cannot appear in a + * header value (whitespace, control characters, non-ASCII) 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. * * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} */ export function encodeCacheTagHeader(tags: readonly string[]): string { - return tags.map(tag => tag.replace(/[^\x21-\x7E]|[%,]/gu, c => encodeURIComponent(c))).join(',') + return tags.map(tag => tag.replace( + /[^\x21-\x7E]|[%,A-Z]/gu, + c => /[A-Z]/.test(c) ? `%${c.charCodeAt(0).toString(16).toUpperCase()}` : encodeURIComponent(c), + )).join(',') } /** From 812995417749d929b04510e130fd8a961acc00e2 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 11:27:41 +0700 Subject: [PATCH 17/18] test(cache): reach full adapter coverage and hoist key encoding --- packages/cache/src/adapters/memory.ts | 7 +- packages/cache/src/adapters/redis.test.ts | 171 ++++++++++++++++++ packages/cache/src/adapters/redis.ts | 5 +- packages/cache/src/adapters/vercel.test.ts | 27 +++ packages/cache/src/adapters/vercel.ts | 5 +- packages/cloudflare/src/kv-cache.test.ts | 19 ++ packages/cloudflare/src/kv-cache.ts | 7 +- packages/cloudflare/src/workers-cache.test.ts | 20 ++ 8 files changed, 251 insertions(+), 10 deletions(-) diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index f27148172..bfb5740ed 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -41,14 +41,15 @@ export class MemoryCacheStore implements CacheStore { } async get(key: unknown): Promise { - const entry = this.entries.get(encodeCacheKey(key, this.serializer)) + const encodedKey = encodeCacheKey(key, this.serializer) + const entry = this.entries.get(encodedKey) if (!entry) { return undefined } if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { - this.entries.delete(encodeCacheKey(key, this.serializer)) + this.entries.delete(encodedKey) return undefined } @@ -57,7 +58,7 @@ export class MemoryCacheStore implements CacheStore { ) if (revalidated) { - this.entries.delete(encodeCacheKey(key, this.serializer)) + this.entries.delete(encodedKey) return undefined } diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 519731bcb..5ec1405e1 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -146,3 +146,174 @@ describe.concurrent('redis cache store integration', { await lazyRedis.destroy() }) }) + +describe('redis cache store with a mocked client', () => { + function createMockedRedis() { + const multi = { + incr: vi.fn(() => multi), + exec: vi.fn(async () => []), + } + + const redis = { + isOpen: true, + connect: vi.fn(async () => { + redis.isOpen = true + }), + get: vi.fn(async (_key: string): Promise => null), + set: vi.fn(async (_key: string, _value: string, _options?: unknown) => 'OK'), + del: vi.fn(async (_key: string) => 1), + incr: vi.fn(async (_key: string) => 1), + mGet: vi.fn(async (_keys: string[]): Promise<(string | null)[]> => []), + multi: vi.fn(() => multi), + } + + return { redis, multi } + } + + function createMockedStore() { + const { redis, multi } = createMockedRedis() + return { store: new RedisCacheStore({ redis: redis as any, prefix: 'p:' }), redis, multi } + } + + it('misses on unknown keys without connecting an open client', async () => { + const { store, redis } = createMockedStore() + + await expect(store.get('k')).resolves.toBeUndefined() + + expect(redis.get).toHaveBeenCalledWith('p:entry:k') + expect(redis.connect).not.toHaveBeenCalled() + }) + + it('lazily connects a closed client', async () => { + const { store, redis } = createMockedStore() + redis.isOpen = false + + await store.get('k') + + expect(redis.connect).toHaveBeenCalledTimes(1) + }) + + it('stores envelopes with snapshotted tag versions and PX retention', async () => { + const { store, redis } = createMockedStore() + redis.mGet.mockResolvedValueOnce(['2']) + + await store.set('k', { a: 1 }, { tags: ['t'], ttl: 1000, swr: 500 }) + + expect(redis.mGet).toHaveBeenCalledWith(['p:tag:t']) + expect(redis.set).toHaveBeenCalledWith( + 'p:entry:k', + expect.stringContaining('"tagVersions":{"t":2}'), + { expiration: { type: 'PX', value: 1500 } }, + ) + }) + + it('stores untagged entries without expiration or tag reads', async () => { + const { store, redis } = createMockedStore() + + await store.set('k', 'v') + + expect(redis.mGet).not.toHaveBeenCalled() + expect(redis.set).toHaveBeenCalledWith('p:entry:k', expect.any(String), undefined) + }) + + it('ignores outputs containing blobs', async () => { + const { store, redis } = createMockedStore() + + await store.set('k', { file: new Blob(['x']) }) + + expect(redis.set).not.toHaveBeenCalled() + }) + + it('round-trips stored envelopes, skipping tag reads for untagged entries', async () => { + const { store, redis } = createMockedStore() + + await store.set('k', { a: 1 }) + redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) + + await expect(store.get('k')).resolves.toEqual({ output: { a: 1 }, tags: [], expiresAt: undefined }) + expect(redis.mGet).not.toHaveBeenCalled() + }) + + it('returns entries whose tag versions still match', async () => { + const { store, redis } = createMockedStore() + redis.mGet.mockResolvedValue(['2']) + + await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) + redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) + + const entry = await store.get('k') + expect(entry!.output).toBe('v') + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(0) + }) + + it('deletes and misses entries whose tag versions changed', async () => { + const { store, redis } = createMockedStore() + redis.mGet.mockResolvedValueOnce(['2']) + + await store.set('k', 'v', { tags: ['t'] }) + redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) + redis.mGet.mockResolvedValueOnce(['3']) // revalidated since the snapshot + + await expect(store.get('k')).resolves.toBeUndefined() + expect(redis.del).toHaveBeenCalledWith('p:entry:k') + }) + + it('revalidates a single tag with one INCR, and many atomically', async () => { + const { store, redis, multi } = createMockedStore() + + await store.revalidateTag('t') + expect(redis.incr).toHaveBeenCalledWith('p:tag:t') + + await store.revalidateTag(['a', 'b']) + expect(multi.incr).toHaveBeenCalledWith('p:tag:a') + expect(multi.incr).toHaveBeenCalledWith('p:tag:b') + expect(multi.exec).toHaveBeenCalledTimes(1) + + await store.revalidateTag([]) + expect(redis.incr).toHaveBeenCalledTimes(1) + expect(multi.exec).toHaveBeenCalledTimes(1) + }) + + it('supports a custom serializer and treats missing tag counters as zero', async () => { + const serializer = new RPCSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const { redis } = createMockedRedis() + const store = new RedisCacheStore({ redis: redis as any }) + + redis.mGet.mockResolvedValueOnce([null]) + await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) + + expect(redis.set).toHaveBeenCalledWith( + 'entry:k', + expect.stringContaining('"tagVersions":{"t":0}'), + { expiration: { type: 'PX', value: 1000 } }, + ) + + const customStore = new RedisCacheStore({ redis: redis as any, serializer }) + redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) + redis.mGet.mockResolvedValueOnce([null]) // still matches the zero snapshot + + await expect(customStore.get('k')).resolves.toMatchObject({ output: 'v' }) + expect(serializeSpy).not.toHaveBeenCalled() // only used for writes and key encoding + }) + + it('treats tags missing from the snapshot as version zero', async () => { + const { store, redis } = createMockedStore() + + redis.get.mockResolvedValueOnce(JSON.stringify({ output: { json: 'v' }, tags: ['t'], tagVersions: {} })) + redis.mGet.mockResolvedValueOnce([null]) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'v' }) + }) + + it('encodes non-string keys stably', async () => { + const { store, redis } = createMockedStore() + + await store.get([['planet', 'find'], { b: 2, a: 1 }]) + await store.get([['planet', 'find'], { a: 1, b: 2 }]) + + expect(redis.get.mock.calls[0]![0]).toBe(redis.get.mock.calls[1]![0]) + expect(redis.get.mock.calls[0]![0]).toMatch(/^p:entry:\[/) + }) +}) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index b42df751d..1dee7da12 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -62,7 +62,8 @@ export class RedisCacheStore implements CacheStore { async get(key: unknown): Promise { await this.ensureConnection() - const raw = await this.redis.get(this.entryKey(key)) + const entryKey = this.entryKey(key) + const raw = await this.redis.get(entryKey) if (raw === null) { return undefined @@ -78,7 +79,7 @@ export class RedisCacheStore implements CacheStore { ) if (revalidated) { - await this.redis.del(this.entryKey(key)) + await this.redis.del(entryKey) return undefined } } diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index e9b317f20..91131631c 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -72,6 +72,24 @@ describe('vercelCacheStore', () => { await expect(store.get('a')).resolves.toBeUndefined() await expect(store.get('b')).resolves.toBeUndefined() }) + + it('defaults to getCache when no cache is given', async () => { + const store = new VercelCacheStore() + const key = crypto.randomUUID() + + await store.set(key, 'v') + + await expect(store.get(key)).resolves.toMatchObject({ output: 'v' }) + }) + + it('skips purging when no tags are given', async () => { + const store = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidateTag([]) + + await expect(store.get('k')).resolves.toBeDefined() + }) }) describe('against a mocked runtime cache', () => { @@ -110,6 +128,15 @@ describe('vercelCacheStore', () => { expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1000, evictAt: 1500 }), { tags: ['t'], ttl: 2 }) }) + it('maps a ttl without swr to its exact retention', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.set('k', 'v', { ttl: 1000 }) + + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ expiresAt: 1000, evictAt: 1000 }), { ttl: 1 }) + }) + it('omits ttl and tags options when unset', async () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index fb5c4f551..346eefd1f 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -51,14 +51,15 @@ export class VercelCacheStore implements CacheStore { } async get(key: unknown): Promise { - const envelope = await this.cache.get(encodeCacheKey(key)) as VercelCacheStoreEnvelope | null | undefined + const encodedKey = encodeCacheKey(key) + const envelope = await this.cache.get(encodedKey) as VercelCacheStoreEnvelope | null | undefined if (envelope == null) { return undefined } if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { - await this.cache.delete(encodeCacheKey(key)) + await this.cache.delete(encodedKey) return undefined } diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index 1f0971a83..98d2fcbdb 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -77,6 +77,15 @@ describe('kvCacheStore', () => { await expect(store.get('other')).resolves.toBeDefined() }) + it('skips revalidation when no tags are given', async () => { + const { store } = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidateTag([]) + + await expect(store.get('k')).resolves.toBeDefined() + }) + it('revalidates many tags at once', async () => { const { store } = createTestingStore() @@ -122,6 +131,16 @@ describe('kvCacheStore', () => { await expect(env.CACHE_KV.get(`${prefix}entry:evicted`)).resolves.toBeNull() }) + it('defaults to no prefix', async () => { + const store = new KVCacheStore({ kv: env.CACHE_KV }) + const key = crypto.randomUUID() + + await store.set(key, 'v') + + await expect(env.CACHE_KV.get(`entry:${key}`)).resolves.toBeTypeOf('string') + await expect(store.get(key)).resolves.toMatchObject({ output: 'v' }) + }) + it('stores entries and tag tokens under the prefixed key families', async () => { const { store, prefix } = createTestingStore() diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index dff5f3fec..0320a3090 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -68,14 +68,15 @@ export class KVCacheStore implements CacheStore { } async get(key: unknown): Promise { - const envelope = await this.kv.get(this.entryKey(key), 'json') + const entryKey = this.entryKey(key) + const envelope = await this.kv.get(entryKey, 'json') if (envelope === null) { return undefined } if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { - await this.kv.delete(this.entryKey(key)) + await this.kv.delete(entryKey) return undefined } @@ -87,7 +88,7 @@ export class KVCacheStore implements CacheStore { ) if (revalidated) { - await this.kv.delete(this.entryKey(key)) + await this.kv.delete(entryKey) return undefined } } diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 4d5be2ec0..17e2370c2 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -34,6 +34,26 @@ describe('workersCacheStore', () => { expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets'] }) }) + it('skips purging when no tags are given', async () => { + const purger = createPurger() + const store = new WorkersCacheStore({ cache: purger }) + + await store.revalidateTag([]) + + expect(purger.purge).not.toHaveBeenCalled() + }) + + it('throws a bare error when the purge fails without messages', async () => { + const purger = { + purge: vi.fn(async () => ({ success: false })), + } + const store = new WorkersCacheStore({ cache: purger }) + + await expect(store.revalidateTag('planets')).rejects.toThrow( + 'WorkersCacheStore failed to purge tags', + ) + }) + it('throws when the purge fails, including error messages', async () => { const purger = { purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), From 17c31c21280a12da228dbcf80a19e6cf5ff36252 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 28 Aug 2026 15:50:30 +0700 Subject: [PATCH 18/18] refactor(cache)!: publish as @orpc/experimental-cache with prefixed cloudflare stores --- README.md | 2 +- apps/content/docs/api-reference.mdx | 2 +- apps/content/docs/helpers/cache.mdx | 28 +++++++++---------- apps/content/package.json | 2 +- eslint.config.js | 1 + package.json | 2 +- packages/ai-sdk/README.md | 2 +- packages/arktype/README.md | 2 +- packages/bun/README.md | 2 +- packages/cache/README.md | 6 ++-- packages/cache/package.json | 2 +- packages/client/README.md | 2 +- packages/cloudflare/README.md | 2 +- packages/cloudflare/package.json | 2 +- packages/cloudflare/src/kv-cache.test.ts | 12 ++++---- packages/cloudflare/src/kv-cache.ts | 10 +++---- packages/cloudflare/src/workers-cache.test.ts | 20 ++++++------- packages/cloudflare/src/workers-cache.ts | 22 +++++++-------- packages/contract/README.md | 2 +- packages/effect/README.md | 2 +- packages/evlog/README.md | 2 +- packages/hibernation/README.md | 2 +- packages/json-schema/README.md | 2 +- packages/nest/README.md | 2 +- packages/next/README.md | 2 +- packages/node/README.md | 2 +- packages/openapi/README.md | 2 +- packages/opentelemetry/README.md | 2 +- packages/pinia-colada/README.md | 2 +- packages/pino/README.md | 2 +- packages/publisher/README.md | 2 +- packages/ratelimit/README.md | 2 +- packages/server/README.md | 2 +- packages/shared/README.md | 2 +- packages/swr/README.md | 2 +- packages/tanstack-query/README.md | 2 +- packages/trpc/README.md | 2 +- packages/valibot/README.md | 2 +- packages/zod/README.md | 2 +- pnpm-lock.yaml | 18 ++++++------ 40 files changed, 91 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 3d3b869f7..38898635b 100644 --- a/README.md +++ b/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index e42693b7a..9f5d452eb 100644 --- a/apps/content/docs/api-reference.mdx +++ b/apps/content/docs/api-reference.mdx @@ -32,7 +32,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | Package | Purpose | Related Guides | | ------- | ------- | -------------- | -| [@orpc/cache](https://npmx.dev/package-docs/@orpc/cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](/docs/helpers/cache) | +| [@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) | diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 73f90d535..812de1f9e 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -8,7 +8,7 @@ sidebar: ## Installation ```package-install -npm install @orpc/cache@beta +npm install @orpc/experimental-cache@beta ``` ## Basic Usage @@ -16,7 +16,7 @@ npm install @orpc/cache@beta 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/cache/memory' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' // ---cut--- const store = new MemoryCacheStore() @@ -39,15 +39,15 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w | `MemoryCacheStore` | In-memory storage | | `RedisCacheStore` | [Redis](https://github.com/redis/redis) | | `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | -| `KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | -| `WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | +| `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. ```ts memory -import { MemoryCacheStore } from '@orpc/cache/memory' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' const store = new MemoryCacheStore({ /** @@ -60,7 +60,7 @@ const store = new MemoryCacheStore({ ``` ```ts redis -import { RedisCacheStore } from '@orpc/cache/redis' +import { RedisCacheStore } from '@orpc/experimental-cache/redis' import { createClient } from 'redis' const client = createClient({ url: 'redis://localhost:6379' }) @@ -93,7 +93,7 @@ const store = new RedisCacheStore({ ``` ```ts vercel -import { VercelCacheStore } from '@orpc/cache/vercel' +import { VercelCacheStore } from '@orpc/experimental-cache/vercel' import { getCache } from '@vercel/functions' const store = new VercelCacheStore({ @@ -116,7 +116,7 @@ const store = new VercelCacheStore({ ``` ```ts cloudflare-kv -import { KVCacheStore } from '@orpc/cloudflare' +import { experimental_KVCacheStore as KVCacheStore } from '@orpc/cloudflare' export default { async fetch(request, env) { @@ -148,7 +148,7 @@ export default { ``` ```ts cloudflare-workers-caching -import { WorkersCacheStore } from '@orpc/cloudflare' +import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare' export default { async fetch(request, env, ctx) { @@ -172,8 +172,8 @@ The `cache` helper creates middleware that caches the output of [procedures](/do 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/cache' -import { MemoryCacheStore } from '@orpc/cache/memory' +import { cache, CacheContext } from '@orpc/experimental-cache' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' const findPlanet = os .$context() @@ -231,7 +231,7 @@ export default { 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/cache' +import { revalidate } from '@orpc/experimental-cache' const updatePlanet = os .$context() @@ -255,7 +255,7 @@ The `CacheHandlerPlugin` reflects the cache activity of [Cache Middleware](#cach 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/cache' +import { CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin } from '@orpc/experimental-cache' const handler = new RPCHandler(router, { plugins: [ @@ -267,7 +267,7 @@ const handler = new RPCHandler(router, { ``` :::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 `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. +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 diff --git a/apps/content/package.json b/apps/content/package.json index e9175b18c..1b7c8a1a9 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -14,10 +14,10 @@ "@opentelemetry/sdk-node": "^0.221.0", "@opentelemetry/sdk-trace-web": "^2.10.0", "@orpc/arktype": "workspace:*", - "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/opentelemetry": "workspace:*", "@orpc/pino": "workspace:*", diff --git a/eslint.config.js b/eslint.config.js index 0f3b84798..4bd85558d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', { diff --git a/package.json b/package.json index ddcdac6ae..aad08e1e6 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,10 @@ "@hono/node-server": "^2.1.1", "@opentelemetry/api": "^1.9.1", "@orpc/arktype": "workspace:*", - "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/experimental-effect": "workspace:*", "@orpc/experimental-msw": "workspace:*", "@orpc/hibernation": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 97d6b24b4..633334cc6 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 1c46709d6..264b187bd 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/bun/README.md b/packages/bun/README.md index c502fa070..703e5a5fe 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -41,7 +41,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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/cache/README.md b/packages/cache/README.md index 9d2ad716a..3419326d2 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -4,8 +4,8 @@ codecov - - weekly downloads + + weekly downloads CodSpeed @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/cache/package.json b/packages/cache/package.json index 2de90a352..2e6ec3eab 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -1,5 +1,5 @@ { - "name": "@orpc/cache", + "name": "@orpc/experimental-cache", "type": "module", "version": "2.0.0-beta.31", "description": "Tag-based caching and revalidation for oRPC procedures, with memory, Redis, and Vercel adapters", diff --git a/packages/client/README.md b/packages/client/README.md index 3d3b869f7..38898635b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 1e833dcca..758ae3496 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 6b17656a7..78125ca7e 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -51,8 +51,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@orpc/cache": "workspace:*", "@orpc/client": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", "@orpc/shared": "workspace:*", diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index 98d2fcbdb..64e2b6de3 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -1,13 +1,13 @@ -import type { KVCacheStoreOptions } from './kv-cache' +import type { experimental_KVCacheStoreOptions } from './kv-cache' import { RPCSerializer } from '@orpc/client' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' -import { KVCacheStore } from './kv-cache' +import { experimental_KVCacheStore } from './kv-cache' -describe('kvCacheStore', () => { - function createTestingStore(options: Partial = {}) { +describe('experimental_KVCacheStore', () => { + function createTestingStore(options: Partial = {}) { const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - return { store: new KVCacheStore({ kv: env.CACHE_KV, prefix, ...options }), prefix } + return { store: new experimental_KVCacheStore({ kv: env.CACHE_KV, prefix, ...options }), prefix } } it('round-trips outputs with tags and expiresAt, including undefined', async () => { @@ -132,7 +132,7 @@ describe('kvCacheStore', () => { }) it('defaults to no prefix', async () => { - const store = new KVCacheStore({ kv: env.CACHE_KV }) + const store = new experimental_KVCacheStore({ kv: env.CACHE_KV }) const key = crypto.randomUUID() await store.set(key, 'v') diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index 0320a3090..2838d86e0 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -1,7 +1,7 @@ -import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' import type { Public } from '@orpc/shared' -import { encodeCacheKey } from '@orpc/cache' import { RPCSerializer } from '@orpc/client' +import { encodeCacheKey } from '@orpc/experimental-cache' import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' interface KVCacheStoreEnvelope { @@ -20,7 +20,7 @@ interface KVCacheStoreEnvelope { evictAt?: number | undefined } -export interface KVCacheStoreOptions { +export interface experimental_KVCacheStoreOptions { /** * The KV namespace to store entries in. */ @@ -56,12 +56,12 @@ export interface KVCacheStoreOptions { * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class KVCacheStore implements CacheStore { +export class experimental_KVCacheStore implements CacheStore { private readonly kv: KVNamespace private readonly prefix: string private readonly serializer: Public - constructor(options: KVCacheStoreOptions) { + constructor(options: experimental_KVCacheStoreOptions) { this.kv = options.kv this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 17e2370c2..b4c9c6492 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -1,14 +1,14 @@ import { describe, expect, it, vi } from 'vitest' -import { WorkersCacheStore } from './workers-cache' +import { experimental_WorkersCacheStore } from './workers-cache' -describe('workersCacheStore', () => { +describe('experimental_WorkersCacheStore', () => { const createPurger = () => ({ purge: vi.fn(async () => ({ success: true })), }) it('always misses and stores nothing', async () => { const purger = createPurger() - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) await expect(store.get('k')).resolves.toBeUndefined() @@ -17,7 +17,7 @@ describe('workersCacheStore', () => { it('purges encoded tags through workers caching', async () => { const purger = createPurger() - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.revalidateTag(['planets', 'a,b']) @@ -27,7 +27,7 @@ describe('workersCacheStore', () => { it('accepts a single tag', async () => { const purger = createPurger() - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.revalidateTag('planets') @@ -36,7 +36,7 @@ describe('workersCacheStore', () => { it('skips purging when no tags are given', async () => { const purger = createPurger() - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.revalidateTag([]) @@ -47,10 +47,10 @@ describe('workersCacheStore', () => { const purger = { purge: vi.fn(async () => ({ success: false })), } - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await expect(store.revalidateTag('planets')).rejects.toThrow( - 'WorkersCacheStore failed to purge tags', + 'experimental_WorkersCacheStore failed to purge tags', ) }) @@ -58,10 +58,10 @@ describe('workersCacheStore', () => { const purger = { purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), } - const store = new WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore({ cache: purger }) await expect(store.revalidateTag('planets')).rejects.toThrow( - 'WorkersCacheStore failed to purge tags: Rate limited', + 'experimental_WorkersCacheStore failed to purge tags: Rate limited', ) }) }) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index 5b4c328d7..daf8a2405 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -1,5 +1,5 @@ -import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/cache' -import { encodeCacheTagHeader } from '@orpc/cache' +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import { encodeCacheTagHeader } from '@orpc/experimental-cache' import { toArray } from '@orpc/shared' /** @@ -8,16 +8,16 @@ import { toArray } from '@orpc/shared' * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export interface WorkersCachePurger { +export interface experimental_WorkersCachePurger { purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> } -export interface WorkersCacheStoreOptions { +export interface experimental_WorkersCacheStoreOptions { /** * The Workers Caching purge surface: `ctx.cache` or `cache` imported * from `cloudflare:workers`. */ - cache: WorkersCachePurger + cache: experimental_WorkersCachePurger } /** @@ -34,18 +34,18 @@ export interface WorkersCacheStoreOptions { * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class WorkersCacheStore implements CacheStore { - private readonly cache: WorkersCachePurger +export class experimental_WorkersCacheStore implements CacheStore { + private readonly cache: experimental_WorkersCachePurger - constructor(options: WorkersCacheStoreOptions) { + constructor(options: experimental_WorkersCacheStoreOptions) { this.cache = options.cache } - async get(_key: string): Promise { + async get(_key: unknown): Promise { return undefined } - async set(_key: string, _output: unknown, _options?: CacheSetOptions): Promise { + async set(_key: unknown, _output: unknown, _options?: CacheSetOptions): Promise { // Storage happens at the response layer, driven by the reflected headers. } @@ -63,7 +63,7 @@ export class WorkersCacheStore implements CacheStore { if (!result.success) { const messages = toArray(result.errors).map(error => error.message).filter(Boolean).join('; ') - throw new Error(`WorkersCacheStore failed to purge tags${messages ? `: ${messages}` : ''}`) + throw new Error(`experimental_WorkersCacheStore failed to purge tags${messages ? `: ${messages}` : ''}`) } } } diff --git a/packages/contract/README.md b/packages/contract/README.md index 87b208095..2eaaf4fcb 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/effect/README.md b/packages/effect/README.md index 3252b5a74..f265077da 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/evlog/README.md b/packages/evlog/README.md index e1f006813..3c398f009 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 5a649e6c9..fff2e4623 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 422052e39..ac495e23b 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/nest/README.md b/packages/nest/README.md index ea551bb95..f99367a53 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/next/README.md b/packages/next/README.md index 9bc6384a2..7903d14d0 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/node/README.md b/packages/node/README.md index 140622d16..9e4f24845 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 5ae24dc7c..9637a86db 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 71c353be0..b251b440e 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index b11e1914f..545693d55 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/pino/README.md b/packages/pino/README.md index 67b9c6732..4742c8e9e 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 69c161193..bd2f4adc7 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index ac25b0ce9..c5fc9c909 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/server/README.md b/packages/server/README.md index db9056667..dcf9552cd 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/shared/README.md b/packages/shared/README.md index e0156d9f2..82418984b 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/swr/README.md b/packages/swr/README.md index 63dee0044..8752f2615 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 9925729d3..8538044d0 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/trpc/README.md b/packages/trpc/README.md index 36d51aaf1..a68ee87f9 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/valibot/README.md b/packages/valibot/README.md index e414c779f..4d532b053 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/packages/zod/README.md b/packages/zod/README.md index aad0c5185..a8a9600e6 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -44,7 +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/cache](https://www.npmjs.com/package/@orpc/cache): Tag-based caching and revalidation with memory, Redis, and Vercel 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75114cafc..b4a2fbb79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,6 @@ importers: '@orpc/arktype': specifier: workspace:* version: link:packages/arktype - '@orpc/cache': - specifier: workspace:* - version: link:packages/cache '@orpc/client': specifier: workspace:* version: link:packages/client @@ -35,6 +32,9 @@ importers: '@orpc/evlog': specifier: workspace:* version: link:packages/evlog + '@orpc/experimental-cache': + specifier: workspace:* + version: link:packages/cache '@orpc/experimental-effect': specifier: workspace:* version: link:packages/effect @@ -209,9 +209,6 @@ importers: '@orpc/arktype': specifier: workspace:* version: link:../../packages/arktype - '@orpc/cache': - specifier: workspace:* - version: link:../../packages/cache '@orpc/client': specifier: workspace:* version: link:../../packages/client @@ -221,6 +218,9 @@ importers: '@orpc/evlog': specifier: workspace:* version: link:../../packages/evlog + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../../packages/cache '@orpc/openapi': specifier: workspace:* version: link:../../packages/openapi @@ -391,12 +391,12 @@ importers: packages/cloudflare: dependencies: - '@orpc/cache': - specifier: workspace:* - version: link:../cache '@orpc/client': specifier: workspace:* version: link:../client + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../cache '@orpc/publisher': specifier: workspace:* version: link:../publisher