From 282fd1ab932eba0deb7294441adb7d49890d4e85 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 27 Aug 2026 14:53:22 +0700 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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 From c2356fbcfdcb85d9845d94ce9dc27a7f87dabd8a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 3 Sep 2026 20:03:31 +0700 Subject: [PATCH 19/35] refactor(cache)!: rework the store contract and header reflection `CacheStore.revalidateTag` becomes `revalidate({ tags })`, taking a non-empty tag list, and every duration is now in seconds rather than milliseconds, matching what Redis, Workers KV, the Vercel Runtime Cache, and `Cache-Control` all accept. Entries without tags carry `undefined` instead of an empty array, and stores no longer inspect output, passing it straight to their serializer. The store and its background-work hook move to the namespaced `cache/store` and `cache/waitUntil` context keys. A background refresh is handed over uncaught so `cache/waitUntil` can report its failures. `revalidate` takes an options object with a required `tags`, and a provided `key` is used as given. The handler plugin takes header names as plain literals rather than exported constants, sets them whatever the request method, and emits `max-age` instead of `s-maxage`, which carries the `proxy-revalidate` semantics that would forbid the stale reuse `stale-while-revalidate` grants. Tag header encoding and `nowInSeconds` move to `@orpc/shared`, and each store builds its key serializer once instead of per call. --- apps/content/docs/helpers/cache.mdx | 59 +-- packages/cache/src/adapters/memory.test.ts | 94 ++--- packages/cache/src/adapters/memory.ts | 35 +- packages/cache/src/adapters/redis.test.ts | 116 +----- packages/cache/src/adapters/redis.ts | 59 ++- packages/cache/src/adapters/vercel.test.ts | 90 +--- packages/cache/src/adapters/vercel.ts | 50 +-- packages/cache/src/handler-plugin.test.ts | 386 +++++++++--------- packages/cache/src/handler-plugin.ts | 116 ++---- packages/cache/src/index.test.ts | 7 +- packages/cache/src/middleware.test-d.ts | 45 +- packages/cache/src/middleware.test.ts | 210 ++++------ packages/cache/src/middleware.ts | 122 +++--- packages/cache/src/types.ts | 45 +- packages/cache/src/utils.ts | 8 +- .../tests/__shared__/store-contract.d.ts | 17 + .../tests/__shared__/store-contract.d.ts.map | 1 + .../cache/tests/__shared__/store-contract.ts | 72 ++++ packages/cache/tests/e2e.test.ts | 10 +- packages/cloudflare/src/kv-cache.test.ts | 81 +--- packages/cloudflare/src/kv-cache.ts | 57 ++- packages/cloudflare/src/workers-cache.test.ts | 24 +- packages/cloudflare/src/workers-cache.ts | 17 +- packages/shared/src/http.test.ts | 49 +++ packages/shared/src/http.ts | 39 ++ packages/shared/src/index.ts | 1 + packages/shared/src/time.test.ts | 12 + packages/shared/src/time.ts | 6 + tests/plugins/all-plugins.test.ts | 2 + 29 files changed, 824 insertions(+), 1006 deletions(-) create mode 100644 packages/cache/tests/__shared__/store-contract.d.ts create mode 100644 packages/cache/tests/__shared__/store-contract.d.ts.map create mode 100644 packages/cache/tests/__shared__/store-contract.ts create mode 100644 packages/shared/src/time.test.ts create mode 100644 packages/shared/src/time.ts diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 812de1f9e..f7f33cad7 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -13,7 +13,7 @@ npm install @orpc/experimental-cache@beta ## Basic Usage -The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. You can create your own custom store or use one of the provided adapters. A router shares a single store, provided through the request context as defined by the `CacheContext` interface. +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 under the `cache/store` key, as defined by the `CacheContext` interface. ```ts twoslash import { MemoryCacheStore } from '@orpc/experimental-cache/memory' @@ -22,15 +22,15 @@ const store = new MemoryCacheStore() await store.set('planet:1', { id: 1, name: 'Earth' }, { tags: ['planets', 'planet:1'], - ttl: 60_000, + ttl: 60, }) const entry = await store.get('planet:1') -await store.revalidateTag('planets') // now `get` misses +await store.revalidate({ tags: ['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. +An entry stays fresh for `ttl` seconds 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 @@ -42,6 +42,8 @@ An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` w | `experimental_KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | | `experimental_WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | +Every duration is in seconds, matching what the underlying caches accept. + 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. @@ -83,8 +85,7 @@ const store = new RedisCacheStore({ prefix: undefined, /** - * Serializer for cached outputs. Outputs containing Blob or File - * values are ignored and never stored. + * Serializer for cached outputs. * * @default RPCSerializer */ @@ -106,8 +107,7 @@ const store = new VercelCacheStore({ cache: getCache(), /** - * Serializer for cached outputs. Outputs containing Blob or File - * values are ignored and never stored. + * Serializer for cached outputs. * * @default RPCSerializer */ @@ -136,8 +136,7 @@ export default { prefix: undefined, /** - * Serializer for cached outputs. Outputs containing Blob or File - * values are ignored and never stored. + * Serializer for cached outputs. * * @default RPCSerializer */ @@ -169,7 +168,7 @@ export default { 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. +The `key` is optional: by default it is derived from the procedure path and input. When provided, it is used as given, so procedures sharing a key also share an entry. ```ts import { cache, CacheContext } from '@orpc/experimental-cache' @@ -182,8 +181,8 @@ const findPlanet = os 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 + ttl: 60, // Optional fresh lifetime in seconds, default is no expiry + swr: 300, // Optional stale-while-revalidate window in seconds, default is 0 }), ) .handler(({ input }) => { @@ -193,12 +192,12 @@ const findPlanet = os const result = await call( findPlanet, { id: 1 }, - { context: { cache: new MemoryCacheStore() } }, + { context: { 'cache/store': 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 +Entries are stored only when the handler succeeds, and stores pass output straight to their serializer. Values it cannot represent, such as [AsyncIteratorObject](/docs/async-iterator-object), readable streams, Blob, and File, will not survive the round trip, so do not cache procedures returning them. ::: :::warning @@ -209,15 +208,15 @@ A cached entry is shared by everyone using the same key. If output depends on th 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: +On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, provide `cache/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), + 'cache/store': store, + 'cache/waitUntil': ctx.waitUntil.bind(ctx), }, }) @@ -226,9 +225,11 @@ export default { } ``` +The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are reported. Without it the refresh still runs, but its failures are ignored and the next stale hit retries. + ## 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. +The `revalidate` helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. The required `tags` option accepts a non-empty list of tags or a function of the middleware options and input. If the procedure throws, or `tags` resolves to `null` or `undefined`, the revalidation is skipped. ```ts import { revalidate } from '@orpc/experimental-cache' @@ -237,7 +238,7 @@ const updatePlanet = os .$context() .input(z.object({ id: z.number(), name: z.string() })) .use( - revalidate((_, input) => ['planets', `planet:${input.id}`]), + revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }), ) .handler(({ input }) => { return input @@ -250,24 +251,28 @@ The `CacheHandlerPlugin` reflects the cache activity of [Cache Middleware](#cach - `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. +- `cache-control` and `cache-tag` are the standard HTTP counterparts for response caches in front, such as CDNs or Cloudflare Workers Caching. + +The plugin sets these over anything already on the response. To override them, set your own afterwards with [ResponseHeadersPlugin](/docs/plugins/response-headers). -Tags are joined with commas. Only `%`, `,`, uppercase letters, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Uppercase letters are encoded because caches like Cloudflare Workers Caching match tags case-insensitively; the encoded form stays unambiguous under case folding. 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` from `@orpc/shared` to parse a header back into tags. ```ts -import { CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin } from '@orpc/experimental-cache' +import { CacheHandlerPlugin } from '@orpc/experimental-cache' const handler = new RPCHandler(router, { plugins: [ new CacheHandlerPlugin({ - headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER], + headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'], }), ], }) ``` :::info[Response Caches in Front] -With `cache-control` and `cache-tag` configured, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `experimental_WorkersCacheStore`, so revalidations also purge the front cache. Since standard HTTP caches only store GET and HEAD responses, this mainly benefits [OpenAPIHandler](/docs/openapi/handler) routes; RPC requests use POST. +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. The plugin sets these whenever the called procedure ran the cache middleware, but standard HTTP caches only store GET and HEAD responses, so this mainly benefits [OpenAPIHandler](/docs/openapi/handler) routes; RPC requests use POST. + +`cache-control` uses `max-age`, not `s-maxage`, because [`s-maxage` carries `proxy-revalidate` semantics](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10) that forbid the stale reuse `swr` asks for. It therefore reaches browser caches too, which no tag purge can invalidate. Set your own `cache-control` when you need responses kept out of them. ::: :::info @@ -279,7 +284,7 @@ The headers use oRPC-specific names on purpose: CDN-facing conventions like `Cac ```ts new CORSPlugin({ - exposeHeaders: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER], + exposeHeaders: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'], }) ``` diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts index 3cf7b8b07..319a3524f 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -1,4 +1,5 @@ import { RPCJsonSerializer } from '@orpc/client' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' import { MemoryCacheStore } from './memory' describe('memoryCacheStore', () => { @@ -11,21 +12,7 @@ describe('memoryCacheStore', () => { 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() - }) + describeCacheStoreContract(() => new MemoryCacheStore()) it('encodes structurally equal non-string keys to the same entry', async () => { const store = new MemoryCacheStore() @@ -37,17 +24,6 @@ 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() @@ -59,11 +35,22 @@ describe('memoryCacheStore', () => { await expect(store.get({ file: new Blob(['b']), id: 1 })).resolves.toMatchObject({ output: 'blobbed' }) }) + 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('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 }) + await store.set('k', 'v', { ttl: 1 }) + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) vi.setSystemTime(999) await expect(store.get('k')).resolves.toBeDefined() @@ -75,61 +62,26 @@ describe('memoryCacheStore', () => { it('returns stale entries within the swr window, then evicts', async () => { const store = new MemoryCacheStore() - await store.set('k', 'v', { ttl: 1000, swr: 500 }) + await store.set('k', 'v', { ttl: 1, swr: 1 }) vi.setSystemTime(1200) // past ttl, within swr - await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: [], expiresAt: 1000 }) + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) - vi.setSystemTime(1500) // past ttl + swr + vi.setSystemTime(2000) // past ttl + swr await expect(store.get('k')).resolves.toBeUndefined() }) - it('invalidates fresh and stale entries by any of their tags', async () => { + it('invalidates stale entries too, and overwrites replace tags and expiry', 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'] }) + await store.set('stale', 'v', { tags: ['a'], ttl: 1, swr: 1 }) + await store.set('k', 'old', { tags: ['old'], ttl: 1 }) + await store.set('k', 'new', { tags: ['new'] }) vi.setSystemTime(1200) // 'stale' is now stale - await store.revalidateTag('a') + await store.revalidate({ tags: ['a', 'old'] }) - 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 index bfb5740ed..691db1d8a 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,7 +1,7 @@ -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 type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import { RPCJsonSerializer } from '@orpc/client' +import { nowInSeconds } from '@orpc/shared' import { encodeCacheKey } from '../utils' export interface MemoryCacheStoreOptions { @@ -15,11 +15,12 @@ export interface MemoryCacheStoreOptions { interface MemoryCacheStoreEntry { output: unknown - tags: readonly string[] /** - * Tag version counters snapshotted at set time, index-aligned with `tags`. + * The tags, and the version counter each had at set time, index-aligned. + * Both are absent together when the entry has no tags. */ - tagVersions: number[] + tags?: readonly string[] + tagVersions?: readonly number[] expiresAt: number | undefined evictAt: number | undefined } @@ -34,10 +35,10 @@ interface MemoryCacheStoreEntry { export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagVersions = new Map() - private readonly serializer: Public | undefined + private readonly serializer: Public constructor(options: MemoryCacheStoreOptions = {}) { - this.serializer = options.serializer + this.serializer = options.serializer ?? new RPCJsonSerializer() } async get(key: unknown): Promise { @@ -48,13 +49,13 @@ export class MemoryCacheStore implements CacheStore { return undefined } - if (entry.evictAt !== undefined && Date.now() >= entry.evictAt) { + if (entry.evictAt !== undefined && nowInSeconds() >= entry.evictAt) { this.entries.delete(encodedKey) return undefined } - const revalidated = entry.tags.some( - (tag, index) => (this.tagVersions.get(tag) ?? 0) !== entry.tagVersions[index], + const revalidated = entry.tags?.some( + (tag, index) => (this.tagVersions.get(tag) ?? 0) !== entry.tagVersions?.[index], ) if (revalidated) { @@ -70,22 +71,22 @@ export class MemoryCacheStore implements CacheStore { } async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const tags = options?.tags ?? [] - const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const tags = options?.tags + const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined this.entries.set(encodeCacheKey(key, this.serializer), { output, tags, - tagVersions: tags.map(tag => this.tagVersions.get(tag) ?? 0), + 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) + async revalidate({ tags }: CacheRevalidateOptions): Promise { + for (const tag of tags) { + this.tagVersions.set(tag, (this.tagVersions.get(tag) ?? 0) + 1) } } } diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 5ec1405e1..b00c140f5 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -1,6 +1,7 @@ import { RPCSerializer } from '@orpc/client' -import { sleep } from '@orpc/shared' +import { nowInSeconds, sleep } from '@orpc/shared' import { createClient } from 'redis' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' import { RedisCacheStore } from './redis' const REDIS_URL = process.env.REDIS_URL @@ -24,44 +25,7 @@ describe.concurrent('redis cache store integration', { 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, 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, - } - - await store.set('k', output) - - await expect(store.get('k')).resolves.toMatchObject({ output }) - }) - - it('ignores outputs containing blobs', async () => { - const { store } = createTestingStore() - - await store.set('k', { file: new Blob(['x']) }) - - await expect(store.get('k')).resolves.toBeUndefined() - }) + describeCacheStoreContract(() => createTestingStore().store) it('supports a custom serializer', async () => { const serializer = new RPCSerializer() @@ -79,57 +43,23 @@ describe.concurrent('redis cache store integration', { 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 store.set('no-swr', 'v', { ttl: 1 }) + await store.set('swr', 'v', { ttl: 1, swr: 10 }) - await sleep(500) + await sleep(1500) 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' }) + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) }) 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 store.revalidate({ tags: ['t'] }) await expect(redis.exists(`${prefix}entry:k`)).resolves.toBe(1) await expect(redis.exists(`${prefix}tag:t`)).resolves.toBe(1) @@ -193,17 +123,17 @@ describe('redis cache store with a mocked client', () => { expect(redis.connect).toHaveBeenCalledTimes(1) }) - it('stores envelopes with snapshotted tag versions and PX retention', async () => { + it('stores envelopes with snapshotted tag versions and EX retention', async () => { const { store, redis } = createMockedStore() redis.mGet.mockResolvedValueOnce(['2']) - await store.set('k', { a: 1 }, { tags: ['t'], ttl: 1000, swr: 500 }) + await store.set('k', { a: 1 }, { tags: ['t'], ttl: 1, swr: 1 }) expect(redis.mGet).toHaveBeenCalledWith(['p:tag:t']) expect(redis.set).toHaveBeenCalledWith( 'p:entry:k', expect.stringContaining('"tagVersions":{"t":2}'), - { expiration: { type: 'PX', value: 1500 } }, + { expiration: { type: 'EX', value: 2 } }, ) }) @@ -216,21 +146,13 @@ describe('redis cache store with a mocked client', () => { 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 }) + await expect(store.get('k')).resolves.toEqual({ output: { a: 1 }, tags: undefined, expiresAt: undefined }) expect(redis.mGet).not.toHaveBeenCalled() }) @@ -238,7 +160,7 @@ describe('redis cache store with a mocked client', () => { const { store, redis } = createMockedStore() redis.mGet.mockResolvedValue(['2']) - await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) + await store.set('k', 'v', { tags: ['t'], ttl: 1 }) redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) const entry = await store.get('k') @@ -262,17 +184,13 @@ describe('redis cache store with a mocked client', () => { it('revalidates a single tag with one INCR, and many atomically', async () => { const { store, redis, multi } = createMockedStore() - await store.revalidateTag('t') + await store.revalidate({ tags: ['t'] }) expect(redis.incr).toHaveBeenCalledWith('p:tag:t') - await store.revalidateTag(['a', 'b']) + await store.revalidate({ tags: ['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 () => { @@ -282,12 +200,12 @@ describe('redis cache store with a mocked client', () => { const store = new RedisCacheStore({ redis: redis as any }) redis.mGet.mockResolvedValueOnce([null]) - await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) + await store.set('k', 'v', { tags: ['t'], ttl: 1 }) expect(redis.set).toHaveBeenCalledWith( 'entry:k', expect.stringContaining('"tagVersions":{"t":0}'), - { expiration: { type: 'PX', value: 1000 } }, + { expiration: { type: 'EX', value: 1 } }, ) const customStore = new RedisCacheStore({ redis: redis as any, serializer }) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index 1dee7da12..6a41deec5 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -1,8 +1,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 { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' +import { nowInSeconds, stringifyJSON } from '@orpc/shared' import { encodeCacheKey } from '../utils' interface RedisCacheStoreEnvelope { @@ -10,11 +10,11 @@ interface RedisCacheStoreEnvelope { * The cached output, encoded with the store's serializer. */ output: unknown - tags: readonly string[] + tags?: readonly string[] /** * Tag version counters snapshotted at set time. */ - tagVersions: Record + tagVersions?: Record expiresAt?: number | undefined } @@ -41,10 +41,9 @@ export interface RedisCacheStoreOptions { /** * Cache store adapter for Redis with tag-based invalidation. Entries are - * retained for `ttl + swr` via `PX` expiry; tag counters have no expiry + * retained for `ttl + swr` via `EX` 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. Outputs containing - * Blob or File values are ignored and never stored. + * are removed lazily on the next `get` of their key. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -53,6 +52,12 @@ export class RedisCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: Public + /** + * Key encoding has no serializer option, so one is built here rather than + * per call by {@link encodeCacheKey}. + */ + private readonly keySerializer = new RPCJsonSerializer() + constructor(options: RedisCacheStoreOptions) { this.redis = options.redis this.prefix = options.prefix ?? '' @@ -71,11 +76,11 @@ export class RedisCacheStore implements CacheStore { const envelope = JSON.parse(raw.toString()) as RedisCacheStoreEnvelope - if (envelope.tags.length) { + 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), + (tag, index) => Number(versions[index] ?? 0) !== (envelope.tagVersions?.[tag] ?? 0), ) if (revalidated) { @@ -94,24 +99,20 @@ export class RedisCacheStore implements CacheStore { 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. - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - return - } - await this.ensureConnection() - const tags = options?.tags ?? [] + const tags = options?.tags - const tagVersions: Record = {} - if (tags.length) { + let tagVersions: Record | undefined + if (tags?.length) { const versions = await this.redis.mGet(tags.map(tag => this.tagKey(tag))) + tagVersions = {} tags.forEach((tag, index) => { - tagVersions[tag] = Number(versions[index] ?? 0) + tagVersions![tag] = Number(versions[index] ?? 0) }) } - const expiresAt = options?.ttl !== undefined ? Date.now() + options.ttl : undefined + const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined const envelope: RedisCacheStoreEnvelope = { @@ -124,33 +125,27 @@ export class RedisCacheStore implements CacheStore { await this.redis.set( this.entryKey(key), stringifyJSON(envelope), - retention !== undefined ? { expiration: { type: 'PX', value: retention } } : undefined, + retention !== undefined ? { expiration: { type: 'EX', value: retention } } : undefined, ) } - async revalidateTag(tag: string | readonly string[]): Promise { + async revalidate({ tags }: CacheRevalidateOptions): Promise { await this.ensureConnection() - const tags = toArray(tag) - - if (!tags.length) { - return - } - if (tags.length === 1) { - await this.redis.incr(this.tagKey(tags[0]!)) + await this.redis.incr(this.tagKey(tags[0])) return } const multi = this.redis.multi() - for (const t of tags) { - multi.incr(this.tagKey(t)) + for (const tag of tags) { + multi.incr(this.tagKey(tag)) } await multi.exec() } private entryKey(key: unknown): string { - return `${this.prefix}entry:${encodeCacheKey(key)}` + return `${this.prefix}entry:${encodeCacheKey(key, this.keySerializer)}` } private tagKey(tag: string): string { diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index 91131631c..886d52a1e 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -1,6 +1,7 @@ import type { RuntimeCache } from '@vercel/functions' import { RPCSerializer } from '@orpc/client' import { getCache } from '@vercel/functions' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' import { VercelCacheStore } from './vercel' describe('vercelCacheStore', () => { @@ -11,67 +12,7 @@ describe('vercelCacheStore', () => { }) } - 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, 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, - } - - await store.set('k', output) - - await expect(store.get('k')).resolves.toMatchObject({ output }) - }) - - it('ignores outputs containing blobs', async () => { - const store = createTestingStore() - - 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 () => { - 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() - }) + describeCacheStoreContract(createTestingStore) it('defaults to getCache when no cache is given', async () => { const store = new VercelCacheStore() @@ -81,15 +22,6 @@ describe('vercelCacheStore', () => { 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', () => { @@ -119,22 +51,22 @@ describe('vercelCacheStore', () => { vi.useRealTimers() }) - it('maps ttl + swr to whole-second retention', async () => { + it('maps ttl + swr to the retention it passes on', async () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) - await store.set('k', 'v', { tags: ['t'], ttl: 1000, swr: 500 }) + await store.set('k', 'v', { tags: ['t'], ttl: 1, swr: 1 }) - expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1000, evictAt: 1500 }), { tags: ['t'], ttl: 2 }) + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1, evictAt: 2 }), { 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 }) + await store.set('k', 'v', { ttl: 1 }) - expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ expiresAt: 1000, evictAt: 1000 }), { ttl: 1 }) + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ expiresAt: 1, evictAt: 1 }), { ttl: 1 }) }) it('omits ttl and tags options when unset', async () => { @@ -143,19 +75,19 @@ describe('vercelCacheStore', () => { await store.set('k', 'v') - expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: [] }), {}) + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: undefined }), {}) }) 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 }) + await store.set('k', 'v', { ttl: 1, swr: 1 }) vi.setSystemTime(1200) // past ttl, within swr - await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: [], expiresAt: 1000 }) + await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) - vi.setSystemTime(1500) // past ttl + swr, backend has not evicted yet + vi.setSystemTime(2000) // past ttl + swr, backend has not evicted yet await expect(store.get('k')).resolves.toBeUndefined() expect(cache.delete).toHaveBeenCalledWith('k') }) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 346eefd1f..4fa8680cf 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -1,8 +1,8 @@ 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 type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' +import { nowInSeconds } from '@orpc/shared' import { getCache } from '@vercel/functions' import { encodeCacheKey } from '../utils' @@ -11,7 +11,7 @@ interface VercelCacheStoreEnvelope { * The cached output, encoded with the store's serializer. */ output: unknown - tags: readonly string[] + tags?: readonly string[] expiresAt?: number | undefined evictAt?: number | undefined } @@ -34,10 +34,9 @@ export interface VercelCacheStoreOptions { /** * 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. Outputs containing Blob or File - * values are ignored and never stored. + * natively via `expireTag`, and entries are retained for `ttl + swr`. + * Outside Vercel, the default `getCache()` falls back to an in-memory + * cache. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -45,20 +44,26 @@ export class VercelCacheStore implements CacheStore { private readonly cache: RuntimeCache private readonly serializer: Public + /** + * Key encoding has no serializer option, so one is built here rather than + * per call by {@link encodeCacheKey}. + */ + private readonly keySerializer = new RPCJsonSerializer() + constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() this.serializer = options.serializer ?? new RPCSerializer() } async get(key: unknown): Promise { - const encodedKey = encodeCacheKey(key) + const encodedKey = encodeCacheKey(key, this.keySerializer) const envelope = await this.cache.get(encodedKey) as VercelCacheStoreEnvelope | null | undefined if (envelope == null) { return undefined } - if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { + if (envelope.evictAt !== undefined && nowInSeconds() >= envelope.evictAt) { await this.cache.delete(encodedKey) return undefined } @@ -73,15 +78,10 @@ export class VercelCacheStore implements CacheStore { 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. - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - return - } - - const tags = options?.tags ?? [] + 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 expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const evictAt = retention !== undefined ? nowInSeconds() + retention : undefined const envelope: VercelCacheStoreEnvelope = { output: serialized, @@ -90,19 +90,13 @@ export class VercelCacheStore implements CacheStore { evictAt, } - await this.cache.set(encodeCacheKey(key), envelope, { - ...(tags.length ? { tags: [...tags] } : {}), - ...(retention !== undefined ? { ttl: Math.ceil(retention / 1000) } : {}), + await this.cache.set(encodeCacheKey(key, this.keySerializer), envelope, { + ...(tags?.length ? { tags: [...tags] } : {}), + ...(retention !== undefined ? { ttl: retention } : {}), }) } - async revalidateTag(tag: string | readonly string[]): Promise { - const tags = toArray(tag) - - if (!tags.length) { - return - } - + async revalidate({ tags }: CacheRevalidateOptions): Promise { await this.cache.expireTag([...tags]) } } diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts index 7e6dd5f5f..69ac087cb 100644 --- a/packages/cache/src/handler-plugin.test.ts +++ b/packages/cache/src/handler-plugin.test.ts @@ -1,274 +1,256 @@ +import type { AnyProcedure } from '@orpc/server' +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardHeaders } from '@standardserver/core' +import type { CacheHandlerPluginContext, CacheHandlerPluginHeader } from './handler-plugin' import type { CacheContext } from './types' import { call, ORPCError, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' +import { decodeCacheTagHeader, toArray } from '@orpc/shared' 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_HANDLER_PLUGIN_CONTEXT_SYMBOL, CacheHandlerPlugin } from './handler-plugin' import { cache, revalidate } from './middleware' -describe('cacheHandlerPlugin', () => { +type RecordedChecks = Exclude +type PartialCheck = Partial & { tags: readonly string[] } + +/** + * Sets response headers from inside the cache plugin's interceptor, standing + * in for a handler or inner plugin that set its own. + */ +function presetHeadersPlugin(preset: StandardHeaders): StandardHandlerPlugin { + return { + name: '~preset-headers', + init: options => ({ + ...options, + interceptors: [...toArray(options.interceptors), async (interceptorOptions) => { + const response = await interceptorOptions.next() + return { ...response, headers: { ...response.headers, ...preset } } + }], + }), + } +} + +function createTestingHandler(headers?: readonly CacheHandlerPluginHeader[], preset?: StandardHeaders) { const handlerFn = vi.fn() - const procedure = os.handler(handlerFn) - const handler = new RPCHandler(procedure, { - allowMethods: ['GET'], // tests below send GET requests + const handler = new RPCHandler(os.handler(handlerFn), { + allowMethods: ['GET', 'POST'], plugins: [ - new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), + new CacheHandlerPlugin({ headers: headers ?? [] }), + // Registered last so its interceptor runs innermost, before the plugin looks. + ...preset ? [presetHeadersPlugin(preset)] : [], ], }) - afterEach(() => { - handlerFn.mockReset() - }) - - it('does nothing by default', async () => { - const defaultHandler = new RPCHandler(procedure, { - allowMethods: ['GET'], - plugins: [new CacheHandlerPlugin()], - }) + return { + handlerFn, + + /** + * Records checks against the called procedure and path, as the + * middlewares do, then runs `then` inside the same handler call. Each + * check may override either field to simulate a nested call. + */ + record(checks: { caches?: PartialCheck[], revalidations?: PartialCheck[] }, then?: () => void) { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + const recorded: RecordedChecks = context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + recorded.caches.push(...toArray(checks.caches).map(check => ({ path, procedure, ...check }))) + recorded.revalidations.push(...toArray(checks.revalidations).map(check => ({ path, procedure, ...check }))) + then?.() + }) + }, + + async handle(init?: RequestInit): Promise { + const { response } = await handler.handle(new Request('http://localhost:3000', init)) + return response! + }, + } +} + +const POST = { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, +} satisfies RequestInit +describe('cacheHandlerPlugin', () => { + it('does nothing until headers are configured', async () => { + const { handlerFn, handle } = createTestingHandler() handlerFn.mockImplementationOnce(({ context }) => { expect(context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]).toBeUndefined() }) - const { response } = await defaultHandler.handle(new Request('http://localhost:3000')) + const response = await handle() expect(handlerFn).toHaveBeenCalledTimes(1) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + expect(response.headers.get('orpc-cache-tag')).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( - { path, procedure, hit: false, stale: false, key: 'k', tags: ['planets', 'planet:1'] }, - { path, procedure, hit: true, stale: false, key: 'k2', tags: ['ignored'] }, - ) - }) + describe('orpc-cache-tag & orpc-cache-tag-invalidation', () => { + const headers = ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] as const - const { response } = await handler.handle(new Request('http://localhost:3000')) + it('reflects the first check of each kind belonging to the called procedure', async () => { + const { record, handle } = createTestingHandler(headers) + record({ + caches: [{ tags: ['planets', 'planet:1'] }, { tags: ['ignored'] }], + revalidations: [{ tags: ['revalidated'] }, { tags: ['ignored'] }], + }) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('planets,planet:1') - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) - }) + const response = await handle() - 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'] }, - ) + expect(response.headers.get('orpc-cache-tag')).toBe('planets,planet:1') + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe('revalidated') }) - 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'] }, - ) - }) + it('sets each header only when its own kind of check ran', async () => { + const cacheOnly = createTestingHandler(headers) + cacheOnly.record({ caches: [{ tags: ['a'] }] }) + const first = await cacheOnly.handle() - const { response } = await handler.handle(new Request('http://localhost:3000')) + expect(first.headers.get('orpc-cache-tag')).toBe('a') + expect(first.headers.get('orpc-cache-tag-invalidation')).toBe(null) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('a') - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('b') - }) + const revalidationOnly = createTestingHandler(headers) + revalidationOnly.record({ revalidations: [{ tags: ['b'] }] }) + const second = await revalidationOnly.handle() - 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'] }, - ) + expect(second.headers.get('orpc-cache-tag')).toBe(null) + expect(second.headers.get('orpc-cache-tag-invalidation')).toBe('b') }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + it('ignores checks recorded for other procedures or paths', async () => { + const other = os.handler(() => 'other') + const { record, handle } = createTestingHandler(headers) + record({ + caches: [ + { procedure: other as AnyProcedure, tags: ['other-procedure'] }, + { path: ['nested'], tags: ['other-path'] }, + ], + revalidations: [{ procedure: other as AnyProcedure, tags: ['other-procedure'] }], + }) + + const response = await handle() + + expect(response.headers.get('orpc-cache-tag')).toBe(null) + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe(null) + }) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) - }) + it('skips headers when no check ran, or its tags are empty', async () => { + const noChecks = await createTestingHandler(headers).handle() - 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('orpc-cache-tag')).toBe(null) + expect(noChecks.headers.get('orpc-cache-tag-invalidation')).toBe(null) - expect(noChecks!.headers.get(CACHE_TAG_HEADER)).toBe(null) - expect(noChecks!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe(null) + const emptyTags = createTestingHandler(headers) + emptyTags.record({ caches: [{ tags: [] }], revalidations: [{ tags: [] }] }) + const response = await emptyTags.handle() - 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: [] }, - ) + expect(response.headers.get('orpc-cache-tag')).toBe(null) + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe(null) }) - const { response: emptyTags } = await handler.handle(new Request('http://localhost:3000')) + it('skips headers on error responses', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets'] }] }, () => { + throw new ORPCError('INTERNAL_SERVER_ERROR') + }) - 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'] }, - ) + const response = await handle() - throw new ORPCError('INTERNAL_SERVER_ERROR') + expect(response.status).toBe(500) + expect(response.headers.get('orpc-cache-tag')).toBe(null) }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + it('percent-encodes tags containing special characters', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['a,b', 'tiαΊΏng việt'] }] }) - expect(response!.status).toBe(500) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe(null) - }) + const header = (await handle()).headers.get('orpc-cache-tag')! - 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'] }, - ) + expect(header).toBe('a%2Cb,ti%E1%BA%BFng%20vi%E1%BB%87t') + expect(decodeCacheTagHeader(header)).toEqual(['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() + describe('cache-control & cache-tag', () => { + const headers = ['cache-control', 'cache-tag'] as const - const inner = os - .$context() - .use(cache({ key: 'inner', tags: ['inner-tag'] })) - .use(revalidate('inner-revalidated')) - .handler(() => 'inner') + it('reflects the root check, leaving unconfigured headers alone', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets', 'a,b'], ttl: 2, swr: 1 }] }) - 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 response = await handle() - const nestedHandler = new RPCHandler({ outer, inner }, { - allowMethods: ['GET'], - plugins: [new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] })], + expect(response.headers.get('orpc-cache-tag')).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, max-age=2, stale-while-revalidate=1') }) - const { response } = await nestedHandler.handle(new Request('http://localhost:3000/outer'), { - context: { cache: store }, - }) + it('holds entries without a ttl for a year, and skips Cache-Tag without tags', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: [] }] }) - expect(response!.headers.get(CACHE_TAG_HEADER)).toBe('outer-tag') - expect(response!.headers.get(CACHE_TAG_INVALIDATION_HEADER)).toBe('outer-revalidated') - }) -}) - -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({ headers: ['cache-control', 'cache-tag'] }), - ], - }) + const response = await handle() - 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 }, - ) + expect(response.headers.get('cache-tag')).toBe(null) + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000') }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + it('reflects the root check whatever the request method', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets'], ttl: 2 }] }) - 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') - }) + const response = await handle(POST) - 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: [] }, - ) + expect(response.headers.get('cache-tag')).toBe('planets') + expect(response.headers.get('cache-control')).toBe('public, max-age=2') }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + it('skips HTTP caching headers without a root cache check', async () => { + const { record, handle } = createTestingHandler(headers) + record({ revalidations: [{ tags: ['planets'] }] }) - expect(response!.headers.get('cache-tag')).toBe(null) - expect(response!.headers.get('cache-control')).toBe('public, s-maxage=31536000') - }) + const response = await handle() - 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 }, - ) + expect(response.headers.get('cache-tag')).toBe(null) + expect(response.headers.get('cache-control')).toBe(null) }) - - 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')).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'] }, - ) - }) + it('sets its headers over ones the response already carries', async () => { + const { record, handle } = createTestingHandler( + ['orpc-cache-tag', 'cache-control'], + { 'orpc-cache-tag': 'preset', 'cache-control': 'private, no-store' }, + ) + record({ caches: [{ tags: ['planets'], ttl: 60 }] }) - const { response } = await handler.handle(new Request('http://localhost:3000')) + const response = await handle() - expect(response!.headers.get('cache-tag')).toBe(null) - expect(response!.headers.get('cache-control')).toBe(null) + expect(response.headers.get('orpc-cache-tag')).toBe('planets') + expect(response.headers.get('cache-control')).toBe('public, max-age=60') }) -}) -describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { - it('round-trips tags with commas, percents, uppercase, and unicode', () => { - const tags = ['plain', 'a,b', '100%', 'CamelCase', 'tiαΊΏng việt', 'sp ace'] + it('only reflects the tags of the procedure the client called in nested calls', async () => { + const inner = os + .$context() + .use(cache({ key: 'inner', tags: ['inner-tag'] })) + .use(revalidate({ tags: ['inner-revalidated'] })) + .handler(() => 'inner') - expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) - }) + const outer = os + .$context() + .use(cache({ key: 'outer', tags: ['outer-tag'] })) + .use(revalidate({ tags: ['outer-revalidated'] })) + .handler(async ({ context }) => `outer:${await call(inner, undefined, { context })}`) - it('percent-encodes uppercase letters so case-insensitive caches keep tags distinct', () => { - expect(encodeCacheTagHeader(['Planets'])).toBe('%50lanets') - expect(encodeCacheTagHeader(['Planets'])).not.toBe(encodeCacheTagHeader(['planets'])) - }) + const handler = new RPCHandler({ outer, inner }, { + allowMethods: ['GET'], + plugins: [new CacheHandlerPlugin({ headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] })], + }) + + const { response } = await handler.handle(new Request('http://localhost:3000/outer'), { + context: { 'cache/store': new MemoryCacheStore() }, + }) - it('decodes empty headers to no tags', () => { - expect(decodeCacheTagHeader('')).toEqual([]) + expect(response!.headers.get('orpc-cache-tag')).toBe('outer-tag') + expect(response!.headers.get('orpc-cache-tag-invalidation')).toBe('outer-revalidated') }) }) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index e6eedb872..db2e9cf8e 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -1,79 +1,38 @@ 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' +import { encodeCacheTagHeader, isDeepEqual, toArray } 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 + * The cache lookups performed during this request, in the order they ran. + * `ttl` carries the remaining freshness in seconds on hits and the * resolved fresh lifetime on stores. */ - caches: { procedure: AnyProcedure, path: string[], hit: boolean, stale: boolean, key: unknown, tags: readonly string[], ttl?: number | undefined, swr?: number | undefined }[] + caches: { procedure: AnyProcedure, path: readonly string[], tags?: readonly string[] | undefined, ttl?: number | undefined, swr?: number | undefined }[] /** - * The tag revalidations committed during this request. + * The tag revalidations committed during this request, in the order they ran. */ - revalidations: { procedure: AnyProcedure, path: string[], tags: readonly string[] }[] + revalidations: { procedure: AnyProcedure, path: readonly 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 `%`, `,`, 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]|[%,A-Z]/gu, - c => /[A-Z]/.test(c) ? `%${c.charCodeAt(0).toString(16).toUpperCase()}` : 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) -} - /** * 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 + = | 'orpc-cache-tag' + | 'orpc-cache-tag-invalidation' | 'cache-control' | 'cache-tag' -export interface CacheHandlerPluginOptions { +export interface CacheHandlerPluginOptions<_T extends Context> { /** * The response headers to set from the root procedure's cache activity; * only listed headers are set. `orpc-cache-tag` carries the tags the @@ -85,7 +44,7 @@ export interface CacheHandlerPluginOptions { * * @default [] */ - headers?: readonly CacheHandlerPluginHeader[] + headers: readonly CacheHandlerPluginHeader[] } /** @@ -102,7 +61,7 @@ export class CacheHandlerPlugin implements StandardHandlerPlu private readonly headers: Set - constructor(options: CacheHandlerPluginOptions = {}) { + constructor(options: CacheHandlerPluginOptions) { this.headers = new Set(options.headers) } @@ -122,46 +81,45 @@ export class CacheHandlerPlugin implements StandardHandlerPlu } 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 isRoot = (check: { procedure: AnyProcedure, path: readonly string[] }) => + check.procedure === interceptorOptions.procedure && isDeepEqual(check.path, interceptorOptions.path) + + const rootCache = pluginContext.caches.find(isRoot) + const rootRevalidation = pluginContext.revalidations.find(isRoot) - const method = interceptorOptions.request.method.toUpperCase() - const isHttpCacheable = rootCache !== undefined && (method === 'GET' || method === 'HEAD') + const headers: StandardHeaders = {} - const headers: StandardHeaders = { ...response.headers } - let changed = false + const cacheTag = rootCache?.tags?.length ? encodeCacheTagHeader(rootCache.tags) : undefined - if (this.headers.has(CACHE_TAG_HEADER) && rootCache?.tags.length) { - headers[CACHE_TAG_HEADER] = encodeCacheTagHeader(rootCache.tags) - changed = true + if (cacheTag !== undefined && this.headers.has('orpc-cache-tag')) { + headers['orpc-cache-tag'] = cacheTag } - if (this.headers.has(CACHE_TAG_INVALIDATION_HEADER) && rootRevalidation?.tags.length) { - headers[CACHE_TAG_INVALIDATION_HEADER] = encodeCacheTagHeader(rootRevalidation.tags) - changed = true + if (rootRevalidation?.tags.length && this.headers.has('orpc-cache-tag-invalidation')) { + headers['orpc-cache-tag-invalidation'] = encodeCacheTagHeader(rootRevalidation.tags) } - if (this.headers.has('cache-tag') && isHttpCacheable && rootCache.tags.length && headers['cache-tag'] === undefined) { - headers['cache-tag'] = encodeCacheTagHeader(rootCache.tags) - changed = true + if (cacheTag !== undefined && this.headers.has('cache-tag')) { + headers['cache-tag'] = cacheTag } - if (this.headers.has('cache-control') && isHttpCacheable && headers['cache-control'] === undefined) { + if (rootCache !== undefined && this.headers.has('cache-control')) { /** - * Entries without a ttl stay valid until revalidated, so front caches - * hold them for a year and rely on tag purges. + * `max-age` rather than `s-maxage`, which carries `proxy-revalidate` + * semantics ([RFC 9111](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10)) + * and so forbids the stale reuse `stale-while-revalidate` grants. + * Entries without a ttl stay valid until revalidated, so 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 + const maxAge = rootCache.ttl ?? 31536000 + const staleWhileRevalidate = rootCache.swr ? `, stale-while-revalidate=${rootCache.swr}` : '' + headers['cache-control'] = `public, max-age=${maxAge}${staleWhileRevalidate}` } - return changed ? { ...response, headers } : response + return { + ...response, + headers: { ...response.headers, ...headers }, + } } return { diff --git a/packages/cache/src/index.test.ts b/packages/cache/src/index.test.ts index 3a9c1cffa..f49364e31 100644 --- a/packages/cache/src/index.test.ts +++ b/packages/cache/src/index.test.ts @@ -1,11 +1,8 @@ -it('exports plugin, middleware factories, and header helpers', async () => { +it('exports the plugin, middleware factories, and key helper', 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', + encodeCacheKey: expect.any(Function), }) }) diff --git a/packages/cache/src/middleware.test-d.ts b/packages/cache/src/middleware.test-d.ts index df439e9fb..767799ad7 100644 --- a/packages/cache/src/middleware.test-d.ts +++ b/packages/cache/src/middleware.test-d.ts @@ -5,7 +5,7 @@ import { cache, revalidate } from './middleware' describe('cache', () => { it('can infer context & input types', () => { os - .$context<{ userId: string, cache: CacheStore }>() + .$context<{ 'userId': string, 'cache/store': CacheStore }>() .input(type<{ id: number }>()) .use(({ next }) => { return next({ @@ -20,7 +20,7 @@ describe('cache', () => { expectTypeOf(input.id).toBeNumber() expectTypeOf(context.userId).toBeString() expectTypeOf(context.db).toBeString() - expectTypeOf(context.cache).toEqualTypeOf() + expectTypeOf(context['cache/store']).toEqualTypeOf() return `planet:${input.id}` }, @@ -52,7 +52,7 @@ describe('cache', () => { }), ) .handler(({ context, input }) => { - expectTypeOf(context.cache).toEqualTypeOf() + expectTypeOf(context['cache/store']).toEqualTypeOf() expectTypeOf(context.userId).toBeString() expectTypeOf(context.db).toBeString() expectTypeOf(input.id).toBeNumber() @@ -61,7 +61,7 @@ describe('cache', () => { }) }) - it('key is optional and accepts non-string material', () => { + it('key is optional and accepts any material', () => { const base = os.$context().input(type<{ id: number }>()) void base.use(cache()) @@ -81,19 +81,21 @@ describe('cache', () => { describe('revalidate', () => { it('can infer context & input types', () => { os - .$context<{ userId: string, cache: CacheStore }>() + .$context<{ 'userId': string, 'cache/store': CacheStore }>() .input(type<{ id: number }>()) .use( - revalidate(async ({ context }, input) => { - expectTypeOf(input.id).toBeNumber() - expectTypeOf(context.userId).toBeString() - expectTypeOf(context.cache).toEqualTypeOf() + revalidate({ + tags: async ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context['cache/store']).toEqualTypeOf() - return `planet:${input.id}` + return [`planet:${input.id}`] + }, }), ) .handler(({ context, input }) => { - expectTypeOf(context.cache).toEqualTypeOf() + expectTypeOf(context['cache/store']).toEqualTypeOf() expectTypeOf(context.userId).toBeString() expectTypeOf(input.id).toBeNumber() @@ -101,21 +103,28 @@ describe('revalidate', () => { }) }) - it('accepts a single tag, a non-empty tag list, but rejects an empty one', () => { + it('requires a non-empty tag list, but a function may decline', () => { const base = os.$context() - void base.use(revalidate('planets')) - void base.use(revalidate(['planets', 'planet:1'])) - void base.use(revalidate(() => ['planets'])) + void base.use(revalidate({ tags: ['planets', 'planet:1'] })) + void base.use(revalidate({ tags: () => ['planets'] })) + void base.use(revalidate({ tags: () => undefined })) + void base.use(revalidate({ tags: () => null })) + + // @ts-expect-error - tags is required + void base.use(revalidate({})) // @ts-expect-error - tags must not be empty - void base.use(revalidate([])) + void base.use(revalidate({ tags: [] })) + + // @ts-expect-error - tags must be a list + void base.use(revalidate({ tags: 'planets' })) }) it('requires the cache store to be declared in the initial context', () => { - void os.$context().use(revalidate('t')) + void os.$context().use(revalidate({ tags: ['t'] })) // @ts-expect-error - initial context must provide the cache store - void os.use(revalidate('t')) + void os.use(revalidate({ tags: ['t'] })) }) }) diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index 127629085..5398e3f34 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -1,6 +1,7 @@ import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext, CacheEntry, CacheStore } from './types' import { call, os, type } from '@orpc/server' +import { nowInSeconds } from '@orpc/shared' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' @@ -8,7 +9,7 @@ function createStore(entry?: CacheEntry) { return { get: vi.fn().mockResolvedValue(entry), set: vi.fn().mockResolvedValue(undefined), - revalidateTag: vi.fn().mockResolvedValue(undefined), + revalidate: vi.fn().mockResolvedValue(undefined), } } @@ -18,16 +19,16 @@ describe('cache', () => { const handlerFn = vi.fn().mockReturnValue('fresh') const procedure = os .$context() - .use(cache({ key: 'k', tags: ['t1', 't2'], ttl: 1000, swr: 500 })) + .use(cache({ key: 'k', tags: ['t1', 't2'], ttl: 60, swr: 30 })) .handler(handlerFn) await expect( - call(procedure, undefined, { context: { cache: store } }), + call(procedure, undefined, { context: { 'cache/store': 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 }) + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t1', 't2'], ttl: 60, swr: 30 }) }) describe('key derivation', () => { @@ -35,10 +36,10 @@ describe('cache', () => { 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'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['user', 'find'] }) const keys = store.get.mock.calls.map(([key]) => key) expect(keys[0]).toEqual([['planet', 'find'], { id: 1 }]) // the procedure path and input @@ -47,7 +48,7 @@ describe('cache', () => { expect(keys[0]).not.toEqual(keys[3]) // different path }) - it('derives the key from non-string key material, and uses string keys verbatim', async () => { + it('uses a provided key as-is, whatever its type', async () => { const store = createStore() const material = os .$context() @@ -56,11 +57,12 @@ describe('cache', () => { .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 } }) + await call(material, { id: 1, page: 1 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(material, { id: 1, page: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(verbatim, undefined, { context: { 'cache/store': store } }) const keys = store.get.mock.calls.map(([key]) => key) + expect(keys[0]).toEqual({ id: 1 }) // the resolved material, not combined with the path expect(keys[0]).toEqual(keys[1]) // same material despite different inputs expect(keys[2]).toBe('k') }) @@ -74,8 +76,8 @@ describe('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 } }) + await call(procedure, { id: 1, page: 1 } as any, { context: { 'cache/store': store } }) + await call(procedure, { id: 1, page: 2 } as any, { context: { 'cache/store': store } }) // The middleware only validated `id` at its position, but the key still // covers the full input, so different pages never share an entry. @@ -84,49 +86,29 @@ describe('cache', () => { }) }) - it('short-circuits the handler on fresh hit', async () => { - const store = createStore({ output: 'cached', tags: ['t'], expiresAt: Date.now() + 1000 }) + it.each<[string, CacheEntry, unknown]>([ + ['a fresh entry', { output: 'cached', tags: ['t'], expiresAt: nowInSeconds() + 60 }, 'cached'], + ['an entry that never expires', { output: 'cached', tags: [] }, 'cached'], + ['a cached undefined output', { output: undefined, tags: [] }, undefined], + ])('serves %s without running the handler', async (_, entry, expected) => { + const store = createStore(entry) 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') + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe(expected) 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 ttlFn = vi.fn().mockResolvedValueOnce(60) + const swrFn = vi.fn().mockResolvedValueOnce(30) const enabledFn = vi.fn().mockResolvedValueOnce(true) const mw = cache({ key: keyFn, tags: tagsFn, ttl: ttlFn, swr: swrFn, enabled: enabledFn }) const procedure = os @@ -136,10 +118,10 @@ describe('cache', () => { .handler(() => 'ok') await expect( - call(procedure, '__input__', { context: { cache: store, __context__: true }, path: ['__path__'] }), + call(procedure, '__input__', { context: { 'cache/store': store, '__context__': true }, path: ['__path__'] }), ).resolves.toBe('ok') - expect(store.set).toHaveBeenCalledWith('k', 'ok', { tags: ['t'], ttl: 1000, swr: 500 }) + expect(store.set).toHaveBeenCalledWith('k', 'ok', { tags: ['t'], ttl: 60, swr: 30 }) for (const fn of [keyFn, tagsFn, ttlFn, swrFn, enabledFn]) { expect(fn).toHaveBeenCalledTimes(1) @@ -156,32 +138,13 @@ describe('cache', () => { const procedure = os.$context().use(cache({ key: 'k', enabled: () => false })).handler(handlerFn) await expect( - call(procedure, undefined, { context: { cache: store } }), + call(procedure, undefined, { context: { 'cache/store': 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: [] } @@ -191,17 +154,17 @@ describe('cache', () => { .handler(() => 'ok') await call(procedure, undefined, { - context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, path: ['__path__'], }) expect(pluginContext.caches).toEqual([ - { procedure, path: ['__path__'], hit: false, stale: false, key: 'k', tags: ['t'] }, + { procedure, path: ['__path__'], 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 store = createStore({ output: 'cached', tags: ['stored'], expiresAt: nowInSeconds() + 60 }) const pluginContext: Exclude = { caches: [], revalidations: [] } const procedure = os .$context() @@ -209,29 +172,19 @@ describe('cache', () => { .handler(() => 'ok') await call(procedure, undefined, { - context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': 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.objectContaining({ procedure, path: ['__path__'], tags: ['stored'] }), ]) expect(pluginContext.caches[0]!.ttl).toBeGreaterThan(0) // the entry's remaining freshness }) - it('propagates store.get failures', async () => { + it.each(['get', 'set'] as const)('propagates store.%s failures and records no check', async (method) => { 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')) + store[method].mockRejectedValueOnce(new Error('store down')) const pluginContext = { caches: [], revalidations: [] } const procedure = os .$context() @@ -240,7 +193,7 @@ describe('cache', () => { await expect( call(procedure, undefined, { - context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, }), ).rejects.toThrow('store down') @@ -249,69 +202,73 @@ describe('cache', () => { 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 store = createStore({ output: 'stale', tags: ['t'], expiresAt: nowInSeconds() - 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 })) + .use(cache({ key: 'k', tags: ['t'], ttl: 60, swr: 30 })) .handler(handlerFn) await expect( call(procedure, undefined, { - context: { cache: store, waitUntil, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': store, 'cache/waitUntil': 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 }, + { procedure, path: ['__path__'], tags: ['t'], ttl: 0, swr: 30 }, ]) 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 }) + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t'], ttl: 60, swr: 30 }) }) it('refreshes in the background without waitUntil', async () => { - const store = createStore({ output: 'stale', tags: [], expiresAt: Date.now() - 1 }) + const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') await expect( - call(procedure, undefined, { context: { cache: store } }), + call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('stale') - await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: [], ttl: undefined, swr: undefined })) + await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined })) }) - it('swallows background refresh failures', async () => { - const store = createStore({ output: 'stale', tags: [], expiresAt: Date.now() - 1 }) + it('hands background refresh failures to waitUntil', async () => { + const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 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 } }), + call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }), ).resolves.toBe('stale') - await expect(waitUntil.mock.calls[0]![0]).resolves.toBeUndefined() + // The raw refresh is handed over, so the runtime can report the failure. + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') 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* () {})()) + it('ignores background refresh failures without waitUntil', async () => { + const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) + const handlerFn = vi.fn(() => { + throw new Error('handler down') + }) + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) await expect( - call(procedure, undefined, { context: { cache: store, waitUntil } }), + call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('stale') - await waitUntil.mock.calls[0]![0] + // Nothing owns the refresh, so its rejection must not reach the process. + await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) expect(store.set).not.toHaveBeenCalled() }) }) @@ -323,42 +280,34 @@ describe('revalidate', () => { const pluginContext = { caches: [], revalidations: [] } const procedure = os .$context() - .use(revalidate('planets')) + .use(revalidate({ tags: ['planets'] })) .handler(() => 'ok') await expect( call(procedure, undefined, { - context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, path: ['__path__'], }), ).resolves.toBe('ok') - expect(store.revalidateTag).toHaveBeenCalledWith(['planets']) + expect(store.revalidate).toHaveBeenCalledWith({ tags: ['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)) + .use(revalidate({ tags: tagsFn })) .handler(() => 'ok') - await call(procedure, '__input__', { context: { cache: store, __context__: true }, path: ['__path__'] }) + await call(procedure, '__input__', { context: { 'cache/store': store, '__context__': true }, path: ['__path__'] }) + expect(store.revalidate).toHaveBeenCalledWith({ tags: ['t'] }) expect(tagsFn).toHaveBeenCalledTimes(1) expect(tagsFn).toHaveBeenCalledWith( expect.objectContaining({ procedure, path: ['__path__'], context: expect.objectContaining({ __context__: true }) }), @@ -368,30 +317,33 @@ describe('revalidate', () => { it('skips the revalidation when the handler throws', async () => { const store = createStore() - const procedure = os.$context().use(revalidate('planets')).handler(() => { + const procedure = os.$context().use(revalidate({ tags: ['planets'] })).handler(() => { throw new Error('handler down') }) await expect( - call(procedure, undefined, { context: { cache: store } }), + call(procedure, undefined, { context: { 'cache/store': store } }), ).rejects.toThrow('handler down') - expect(store.revalidateTag).not.toHaveBeenCalled() + expect(store.revalidate).not.toHaveBeenCalled() }) - it('skips the revalidation and recording when tags resolve to empty', async () => { + it.each([ + ['undefined', undefined], + ['null', null], + ])('skips the revalidation and recording when tags resolve to %s', async (_, tags) => { const store = createStore() const pluginContext = { caches: [], revalidations: [] } const procedure = os .$context() - .use(revalidate(() => [] as unknown as [string, ...string[]])) + .use(revalidate({ tags: () => tags })) .handler(() => 'ok') await call(procedure, undefined, { - context: { cache: store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, }) - expect(store.revalidateTag).not.toHaveBeenCalled() + expect(store.revalidate).not.toHaveBeenCalled() expect(pluginContext.revalidations).toEqual([]) }) }) @@ -402,21 +354,21 @@ describe('cache + revalidate combined', () => { const procedure = os .$context() .use(cache({ key: 'k', tags: ['t'] })) - .use(revalidate('t')) + .use(revalidate({ tags: ['t'] })) .handler(() => 'ok') - await call(procedure, undefined, { context: { cache: store } }) + await call(procedure, undefined, { context: { 'cache/store': store } }) - expect(store.revalidateTag).toHaveBeenCalledTimes(1) + expect(store.revalidate).toHaveBeenCalledTimes(1) expect(store.set).toHaveBeenCalledTimes(1) - expect(store.revalidateTag.mock.invocationCallOrder[0]!).toBeLessThan(store.set.mock.invocationCallOrder[0]!) + expect(store.revalidate.mock.invocationCallOrder[0]!).toBeLessThan(store.set.mock.invocationCallOrder[0]!) store.get.mockResolvedValueOnce({ output: 'cached', tags: ['t'] }) await expect( - call(procedure, undefined, { context: { cache: store } }), + call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('cached') - expect(store.revalidateTag).toHaveBeenCalledTimes(1) + expect(store.revalidate).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index e52aa0577..691ac65cb 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -1,77 +1,69 @@ -import type { Context, Middleware, MiddlewareOptions } from '@orpc/server' +import type { Middleware, MiddlewareOptions } from '@orpc/server' import type { Promisable, Value } from '@orpc/shared' import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext } from './types' -import { isAsyncIteratorObject, toArray, value } from '@orpc/shared' +import { nowInSeconds, 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, + TInContext extends CacheContext, 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 by the store. + * it from. Used as given, so procedures sharing a key share an entry. * * @default the procedure path and input */ - key?: Value, [options: MiddlewareOptions>, input: TInput]> + // Spelled out instead of `unknown`, which absorbs the function form and drops its contextual typing. + 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]> + tags?: Value, [options: MiddlewareOptions>, input: TInput]> /** - * Fresh lifetime in milliseconds. `undefined` means the entry never expires by time. + * Fresh lifetime in seconds. `undefined` means the entry never expires by time. * * @default undefined */ - ttl?: Value, [options: MiddlewareOptions>, input: TInput]> + ttl?: Value, [options: MiddlewareOptions>, input: TInput]> /** - * Extra stale-while-revalidate window in milliseconds after `ttl`. + * Extra stale-while-revalidate window in seconds after `ttl`. * Stale entries are served immediately while the procedure re-executes in the background. * * @default 0 */ - swr?: Value, [options: MiddlewareOptions>, input: TInput]> + 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]> + enabled?: Value, [options: MiddlewareOptions>, input: TInput]> } /** - * Creates a middleware that caches procedure output in the `context.cache` store, + * Creates a middleware that caches procedure output in the context's `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, + TInContext extends CacheContext, TInput, >( options: CacheMiddlewareOptions = {}, -): Middleware { +): 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, + const [keyMaterial, tags, ttl, swr, enabled = true] = await Promise.all([ + value(options.key, middlewareOptions, input), value(options.tags, middlewareOptions, input), value(options.ttl, middlewareOptions, input), value(options.swr, middlewareOptions, input), @@ -82,41 +74,41 @@ export function cache< return middlewareOptions.next() } - const key = typeof keyMaterial === 'string' ? keyMaterial : [middlewareOptions.path, keyMaterial] + const key = 'key' in options ? keyMaterial : [middlewareOptions.path, input] - const { cache: store, waitUntil } = middlewareOptions.context as CacheContext + const store = middlewareOptions.context['cache/store'] 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 + /** + * The entry's remaining freshness, so reflected HTTP caching headers never + * outlive the store entry. `0` means the entry is stale. + */ + const remainingTtl = entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - nowInSeconds()) : undefined - if (stale) { + if (remainingTtl === 0) { 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) + .then(result => store.set(key, result.output, { tags, ttl, swr })) + + const waitUntil = middlewareOptions.context['cache/waitUntil'] + + if (waitUntil !== undefined) { + // The runtime owns the refresh from here, failures included. + waitUntil(refresh) + } + else { + // Nothing owns it instead, and Node exits on an unhandled rejection. + refresh.catch(() => {}) + } } 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, + ttl: remainingTtl, swr, }) @@ -125,18 +117,11 @@ export function cache< 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, @@ -146,38 +131,45 @@ export function cache< } } +export interface RevalidateMiddlewareOptions< + TInContext extends CacheContext, + TInput, +> { + /** + * The tags to revalidate. Resolving to `null` or `undefined` skips the revalidation. + */ + tags: Value, [options: MiddlewareOptions>, input: TInput]> +} + /** - * Creates a middleware that revalidates cache tags in the `context.cache` store + * Creates a middleware that revalidates cache tags in the context's `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, + TInContext extends CacheContext, TInput, >( - tags: Value, [options: MiddlewareOptions>, input: TInput]>, -): Middleware { + options: RevalidateMiddlewareOptions, +): Middleware { return async function revalidate(middlewareOptions, input) { const result = await middlewareOptions.next() - const resolvedTags = toArray(await value(tags, middlewareOptions, input)) + const tags = await value(options.tags, middlewareOptions, input) - if (resolvedTags.length) { - await (middlewareOptions.context as CacheContext).cache.revalidateTag(resolvedTags as [string, ...string[]]) + if (tags) { + const store = middlewareOptions.context['cache/store'] + await store.revalidate({ tags }) const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] pluginContext?.revalidations.push({ procedure: middlewareOptions.procedure, path: middlewareOptions.path, - tags: resolvedTags, + tags, }) } return result } } - -function isUncacheableOutput(output: unknown): boolean { - return isAsyncIteratorObject(output) || output instanceof ReadableStream -} diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index dc096eef3..a79311444 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -1,8 +1,3 @@ -/** - * 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. @@ -10,22 +5,17 @@ export interface CacheEntry { output: unknown /** - * The tags recorded when the entry was stored. + * The tags recorded when the entry was stored, absent when it has none. */ - tags: readonly string[] + tags?: readonly string[] | undefined /** - * The time (unix timestamp in milliseconds) when the entry stops being fresh. + * The time (unix timestamp in seconds) 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. @@ -35,14 +25,14 @@ export interface CacheSetOptions { tags?: readonly string[] /** - * Fresh lifetime in milliseconds. `undefined` means the entry never expires by time. + * Fresh lifetime in seconds. `undefined` means the entry never expires by time. * * @default undefined */ ttl?: number /** - * Extra stale-while-revalidate window in milliseconds after `ttl`. + * Extra stale-while-revalidate window in seconds after `ttl`. * During this window the store still returns the entry with a past `expiresAt`. * Ignored when `ttl` is `undefined`. * @@ -51,9 +41,16 @@ export interface CacheSetOptions { swr?: number } +export interface CacheRevalidateOptions { + /** + * The tags to revalidate. + */ + tags: readonly [string, ...string[]] +} + /** * Storage contract used by the cache middleware. Implementations own - * expiry and tag tracking: `set` records tags, `revalidateTag` invalidates + * expiry and tag tracking: `set` records tags, `revalidate` invalidates * every entry associated with them. * * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} @@ -73,9 +70,9 @@ export interface CacheStore { set(key: unknown, output: unknown, options?: CacheSetOptions): Promise /** - * Invalidates every entry associated with one or many tags. + * Invalidates every entry associated with any of the given tags. */ - revalidateTag(tag: string | readonly [string, ...string[]]): Promise + revalidate(options: CacheRevalidateOptions): Promise } /** @@ -87,12 +84,14 @@ export interface CacheContext { /** * The cache store shared by every cached procedure behind one handler. */ - cache: CacheStore + 'cache/store': 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`). + * Takes ownership of 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`). The promise rejects + * when the refresh fails, so this is also where such failures are reported; + * without it they are ignored. */ - waitUntil?: (promise: Promise) => void + 'cache/waitUntil'?: (promise: Promise) => void } diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index 45944d6ca..f97b09531 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -11,12 +11,14 @@ import { deepSortKeys, stringifyJSON } from '@orpc/shared' * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export function encodeCacheKey(key: unknown, serializer: Public = new RPCJsonSerializer()): string { +export function encodeCacheKey(key: unknown, serializer?: Public): string { if (typeof key === 'string') { return key } - const { json, meta } = serializer.serialize(key) + // Built here rather than as a default parameter, which would construct one + // on every call, string keys included. + const { json, meta } = (serializer ?? new RPCJsonSerializer()).serialize(key) - return `${stringifyJSON(deepSortKeys([json, meta?.map(entry => stringifyJSON(entry)).sort()]))}` + return `${stringifyJSON([deepSortKeys(json), meta?.map(entry => stringifyJSON(entry)).sort()])}` } diff --git a/packages/cache/tests/__shared__/store-contract.d.ts b/packages/cache/tests/__shared__/store-contract.d.ts new file mode 100644 index 000000000..9880b2ca6 --- /dev/null +++ b/packages/cache/tests/__shared__/store-contract.d.ts @@ -0,0 +1,17 @@ +import type { CacheStore } from '../../src' + +export interface CacheStoreContractOptions { + /** + * Whether the store serializes outputs, and so drops values it cannot + * encode instead of storing them. + * + * @default false + */ + serializes?: boolean +} +/** + * The behavior every {@link CacheStore} must share, run against one adapter. + * Adapter suites keep only what is specific to their backend. + */ +export declare function describeCacheStoreContract(createStore: () => CacheStore, options?: CacheStoreContractOptions): void +// # sourceMappingURL=store-contract.d.ts.map diff --git a/packages/cache/tests/__shared__/store-contract.d.ts.map b/packages/cache/tests/__shared__/store-contract.d.ts.map new file mode 100644 index 000000000..a4b989527 --- /dev/null +++ b/packages/cache/tests/__shared__/store-contract.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"store-contract.d.ts","sourceRoot":"","sources":["store-contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAG3C,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,UAAU,EAC7B,OAAO,GAAE,yBAA8B,GACtC,IAAI,CA0EN"} \ No newline at end of file diff --git a/packages/cache/tests/__shared__/store-contract.ts b/packages/cache/tests/__shared__/store-contract.ts new file mode 100644 index 000000000..202015ed2 --- /dev/null +++ b/packages/cache/tests/__shared__/store-contract.ts @@ -0,0 +1,72 @@ +import type { CacheStore } from '../../src' +import { expect, it } from 'vitest' + +/** + * The behavior every {@link CacheStore} must share, run against one adapter. + * Adapter suites keep only what is specific to their backend. + */ +export function describeCacheStoreContract(createStore: () => CacheStore): void { + it('round-trips outputs with their tags, including undefined', async () => { + const store = createStore() + + 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: undefined, expiresAt: undefined }) + }) + + it('misses on unknown keys', async () => { + const store = createStore() + + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + it('preserves Date, Map, Set, and BigInt outputs', async () => { + const store = createStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + } + + await store.set('k', output) + + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('invalidates entries by any of their tags, leaving others alone', async () => { + const store = createStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidate({ tags: ['a'] }) + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + }) + + it('revalidates many tags at once', async () => { + const store = createStore() + + await store.set('a', 'v', { tags: ['a'] }) + await store.set('b', 'v', { tags: ['b'] }) + + await store.revalidate({ tags: ['a', 'b'] }) + + await expect(store.get('a')).resolves.toBeUndefined() + await expect(store.get('b')).resolves.toBeUndefined() + }) + + it('keeps entries set after a revalidation', async () => { + const store = createStore() + + await store.set('k', 'old', { tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) + await store.set('k', 'new', { tags: ['t'] }) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'new' }) + }) +} diff --git a/packages/cache/tests/e2e.test.ts b/packages/cache/tests/e2e.test.ts index d42fb0281..1fe72f6bb 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, CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin, revalidate } from '../src' +import { cache, CacheHandlerPlugin, revalidate } from '../src' import { MemoryCacheStore } from '../src/adapters/memory' it('works', async () => { @@ -24,7 +24,7 @@ it('works', async () => { .$context() .input(z.object({ id: z.number(), name: z.string() })) .use( - revalidate((_, input) => ['planets', `planet:${input.id}`]), + revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }), ) .handler(({ input }) => input), }, @@ -32,7 +32,7 @@ it('works', async () => { const handler = new RPCHandler(router, { plugins: [ - new CacheHandlerPlugin({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), + new CacheHandlerPlugin({ headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] }), ], }) @@ -47,7 +47,7 @@ it('works', async () => { }) const find = () => handler.handle(request('planet/find', { id: 1 }), { - context: { cache: store }, + context: { 'cache/store': store }, }) // miss: the handler runs and the response carries the cache tags @@ -65,7 +65,7 @@ it('works', async () => { // 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 }, + context: { 'cache/store': store }, }) expect(update.response?.status).toBe(200) expect(update.response?.headers.get('orpc-cache-tag-invalidation')).toBe('planets,planet:1') diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index 64e2b6de3..eca27a0f7 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -1,5 +1,6 @@ import type { experimental_KVCacheStoreOptions } from './kv-cache' import { RPCSerializer } from '@orpc/client' +import { nowInSeconds } from '@orpc/shared' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' import { experimental_KVCacheStore } from './kv-cache' @@ -10,23 +11,20 @@ describe('experimental_KVCacheStore', () => { return { store: new experimental_KVCacheStore({ kv: env.CACHE_KV, prefix, ...options }), prefix } } - it('round-trips outputs with tags and expiresAt, including undefined', async () => { + // The cross-package tsconfig rootDir keeps the shared store contract out of + // reach here, so the shared behavior is asserted again against real KV. + it('round-trips outputs with their tags and expiresAt, including undefined', async () => { const { store } = createTestingStore() - await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120_000 }) + await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120 }) const entry = await store.get('k') expect(entry!.output).toEqual({ nested: [1, 2] }) expect(entry!.tags).toEqual(['t']) - expect(entry!.expiresAt).toBeGreaterThan(Date.now()) + expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) 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('u')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) await expect(store.get('unknown')).resolves.toBeUndefined() }) @@ -40,16 +38,22 @@ describe('experimental_KVCacheStore', () => { } await store.set('k', output) - await expect(store.get('k')).resolves.toMatchObject({ output }) }) - it('ignores outputs containing blobs', async () => { + it('invalidates entries by any of their tags, and keeps ones set afterwards', async () => { const { store } = createTestingStore() - await store.set('k', { file: new Blob(['x']) }) + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidate({ tags: ['a', 'b'] }) - await expect(store.get('k')).resolves.toBeUndefined() + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + + await store.set('multi', 'new', { tags: ['a'] }) + await expect(store.get('multi')).resolves.toMatchObject({ output: 'new' }) }) it('supports a custom serializer', async () => { @@ -65,49 +69,6 @@ describe('experimental_KVCacheStore', () => { expect(deserializeSpy).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('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() - - 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() @@ -120,12 +81,12 @@ describe('experimental_KVCacheStore', () => { 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)) + await env.CACHE_KV.put(`${prefix}entry:stale`, envelope(nowInSeconds() - 1, nowInSeconds() + 60)) + await env.CACHE_KV.put(`${prefix}entry:evicted`, envelope(nowInSeconds() - 2, nowInSeconds() - 1)) const stale = await store.get('stale') expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(Date.now()) + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) await expect(store.get('evicted')).resolves.toBeUndefined() await expect(env.CACHE_KV.get(`${prefix}entry:evicted`)).resolves.toBeNull() @@ -145,7 +106,7 @@ describe('experimental_KVCacheStore', () => { const { store, prefix } = createTestingStore() await store.set('k', 'v', { tags: ['t'] }) - await store.revalidateTag('t') + await store.revalidate({ tags: ['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/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index 2838d86e0..b2438632e 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -1,21 +1,21 @@ -import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' import type { Public } from '@orpc/shared' -import { RPCSerializer } from '@orpc/client' +import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { encodeCacheKey } from '@orpc/experimental-cache' -import { isAsyncIteratorObject, stringifyJSON, toArray } from '@orpc/shared' +import { nowInSeconds, stringifyJSON } from '@orpc/shared' interface KVCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. */ output: unknown - tags: readonly 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 + tagTokens?: Record expiresAt?: number | undefined evictAt?: number | undefined } @@ -46,8 +46,7 @@ export interface experimental_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`. Outputs containing Blob or File values are - * ignored and never stored. + * still enforced on `get`. * * @remarks * **Note**: KV is [eventually consistent](https://developers.cloudflare.com/kv/concepts/how-kv-works/#consistency): @@ -61,6 +60,12 @@ export class experimental_KVCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: Public + /** + * Key encoding has no serializer option, so one is built here rather than + * per call by {@link encodeCacheKey}. + */ + private readonly keySerializer = new RPCJsonSerializer() + constructor(options: experimental_KVCacheStoreOptions) { this.kv = options.kv this.prefix = options.prefix ?? '' @@ -75,16 +80,16 @@ export class experimental_KVCacheStore implements CacheStore { return undefined } - if (envelope.evictAt !== undefined && Date.now() >= envelope.evictAt) { + if (envelope.evictAt !== undefined && nowInSeconds() >= envelope.evictAt) { await this.kv.delete(entryKey) return undefined } - if (envelope.tags.length) { + 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), + (tag, index) => tokens[index] !== (envelope.tagTokens?.[tag] ?? null), ) if (revalidated) { @@ -103,24 +108,20 @@ export class experimental_KVCacheStore implements CacheStore { 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. - if (serialized instanceof Blob || serialized instanceof FormData || serialized instanceof ReadableStream || isAsyncIteratorObject(serialized)) { - return - } - - const tags = options?.tags ?? [] + const tags = options?.tags - const tagTokens: Record = {} - if (tags.length) { + let tagTokens: Record | undefined + if (tags?.length) { const tokens = await Promise.all(tags.map(tag => this.kv.get(this.tagKey(tag)))) + tagTokens = {} tags.forEach((tag, index) => { - tagTokens[tag] = tokens[index] ?? null + 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 expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const evictAt = retention !== undefined ? nowInSeconds() + retention : undefined const envelope: KVCacheStoreEnvelope = { output: serialized, @@ -134,22 +135,16 @@ export class experimental_KVCacheStore implements CacheStore { 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)) } : {}, + retention !== undefined ? { expirationTtl: Math.max(60, retention) } : {}, ) } - 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()))) + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await Promise.all(tags.map(tag => this.kv.put(this.tagKey(tag), crypto.randomUUID()))) } private entryKey(key: unknown): string { - return `${this.prefix}entry:${encodeCacheKey(key)}` + return `${this.prefix}entry:${encodeCacheKey(key, this.keySerializer)}` } private tagKey(tag: string): string { diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index b4c9c6492..647901a7a 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -19,37 +19,19 @@ describe('experimental_WorkersCacheStore', () => { const purger = createPurger() const store = new experimental_WorkersCacheStore({ cache: purger }) - await store.revalidateTag(['planets', 'a,b']) + await store.revalidate({ tags: ['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 experimental_WorkersCacheStore({ cache: purger }) - - await store.revalidateTag('planets') - - expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets'] }) - }) - - it('skips purging when no tags are given', async () => { - const purger = createPurger() - const store = new experimental_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 experimental_WorkersCacheStore({ cache: purger }) - await expect(store.revalidateTag('planets')).rejects.toThrow( + await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( 'experimental_WorkersCacheStore failed to purge tags', ) }) @@ -60,7 +42,7 @@ describe('experimental_WorkersCacheStore', () => { } const store = new experimental_WorkersCacheStore({ cache: purger }) - await expect(store.revalidateTag('planets')).rejects.toThrow( + await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( '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 daf8a2405..95cffec12 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -1,6 +1,5 @@ -import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' -import { encodeCacheTagHeader } from '@orpc/experimental-cache' -import { toArray } from '@orpc/shared' +import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import { encodeCacheTagHeader, toArray } from '@orpc/shared' /** * The purge surface of Cloudflare Workers Caching, satisfied by both @@ -24,7 +23,7 @@ export interface experimental_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` `headers` option), so `get` always misses and - * `set` stores nothing; `revalidateTag` purges the tags through Workers + * `set` stores nothing; `revalidate` purges the tags through Workers * Caching. * * @remarks @@ -49,16 +48,10 @@ export class experimental_WorkersCacheStore implements CacheStore { // 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 - } - + async revalidate({ tags }: CacheRevalidateOptions): Promise { 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])), + tags: tags.map(tag => encodeCacheTagHeader([tag])), }) if (!result.success) { diff --git a/packages/shared/src/http.test.ts b/packages/shared/src/http.test.ts index 77fcb41ed..05425d18f 100644 --- a/packages/shared/src/http.test.ts +++ b/packages/shared/src/http.test.ts @@ -1,4 +1,6 @@ import { + decodeCacheTagHeader, + encodeCacheTagHeader, isCompressibleContentType, isNoTransformCacheControl, matchesHttpPath, @@ -248,3 +250,50 @@ describe('isNoTransformCacheControl', () => { expect(isNoTransformCacheControl('no-transform-extension')).toBe(false) }) }) + +describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { + it.each([ + ['leaves plain tags alone', 'planets', 'planets'], + ['leaves other printable ASCII alone', 'a1!~*\'()-_.:/?', 'a1!~*\'()-_.:/?'], + ['escapes the comma separator', 'a,b', 'a%2Cb'], + ['escapes the percent escape', '100%', '100%25'], + ['escapes uppercase letters by code point', 'Planets', '%50lanets'], + ['escapes spaces', 'sp ace', 'sp%20ace'], + ['escapes control characters', 'a\nb', 'a%0Ab'], + ['escapes delete', 'a\x7Fb', 'a%7Fb'], + ['escapes non-ASCII as UTF-8', 'tiαΊΏng việt', 'ti%E1%BA%BFng%20vi%E1%BB%87t'], + ['escapes astral characters as UTF-8', 'aπŸ˜€', 'a%F0%9F%98%80'], + ])('%s', (_, tag, encoded) => { + expect(encodeCacheTagHeader([tag])).toBe(encoded) + expect(decodeCacheTagHeader(encoded)).toEqual([tag]) + }) + + it('joins tags with commas, and round-trips the whole list', () => { + const tags = ['plain', 'a,b', '100%', 'CamelCase', 'tiαΊΏng việt', 'sp ace'] + + expect(encodeCacheTagHeader(['a', 'b'])).toBe('a,b') + expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) + }) + + it('keeps case-folded tags distinct', () => { + expect(encodeCacheTagHeader(['Planets'])).not.toBe(encodeCacheTagHeader(['planets'])) + expect(encodeCacheTagHeader(['Planets']).toLowerCase()).not.toBe(encodeCacheTagHeader(['planets']).toLowerCase()) + }) + + it('keeps empty tags instead of dropping them', () => { + expect(encodeCacheTagHeader(['a', '', 'b'])).toBe('a,,b') + expect(decodeCacheTagHeader('a,,b')).toEqual(['a', '', 'b']) + expect(decodeCacheTagHeader('')).toEqual(['']) + }) + + it('encodes identically across calls, since the pattern is shared', () => { + const tags = ['A,B%C', 'tiαΊΏng việt'] + + expect(encodeCacheTagHeader(tags)).toBe(encodeCacheTagHeader(tags)) + expect(encodeCacheTagHeader(tags)).toBe('%41%2C%42%25%43,ti%E1%BA%BFng%20vi%E1%BB%87t') + }) + + it('decodes malformed escapes as-is', () => { + expect(decodeCacheTagHeader('%zz')).toEqual(['%zz']) + }) +}) diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index b037e89c0..31d164535 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -129,3 +129,42 @@ export function isCompressibleContentType(contentType: string | null | undefined return COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType) } + +/** + * Characters a cache tag cannot carry literally: `%` and `,` because they are + * the encoding's own syntax, `A-Z` because caches like Cloudflare Workers + * Caching fold case, and anything outside printable ASCII because it cannot + * appear in a header value. + * + * Safe to share across calls: `String.prototype.replace` resets `lastIndex` + * on a global regex before it matches. + */ +const UNSAFE_CACHE_TAG_CHARS = /[^\x21-\x7E]|[%,A-Z]/gu + +/** + * Encodes cache tags into a header value: tags are joined with commas, and + * only {@link UNSAFE_CACHE_TAG_CHARS} 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( + UNSAFE_CACHE_TAG_CHARS, + char => char >= 'A' && char <= 'Z' + ? `%${char.charCodeAt(0).toString(16).toUpperCase()}` + : encodeURIComponent(char), + )).join(',') +} + +/** + * Decodes a header value produced by {@link encodeCacheTagHeader} back into + * tags, empty ones included. Every non-empty tag list round-trips exactly. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function decodeCacheTagHeader(header: string): string[] { + return header.split(',').map(tryDecodeURIComponent) +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c8fac2d46..766c5fad5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,6 +17,7 @@ export * from './proxy' export * from './queue' export * from './signal' export * from './stream' +export * from './time' export * from './types' export * from './uri' export * from './value' diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts new file mode 100644 index 000000000..0212ed38f --- /dev/null +++ b/packages/shared/src/time.test.ts @@ -0,0 +1,12 @@ +import { nowInSeconds } from './time' + +describe('nowInSeconds', () => { + it('floors the current time to whole seconds', () => { + vi.useFakeTimers() + vi.setSystemTime(1999) + + expect(nowInSeconds()).toBe(1) + + vi.useRealTimers() + }) +}) diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts new file mode 100644 index 000000000..7ff3e4ccd --- /dev/null +++ b/packages/shared/src/time.ts @@ -0,0 +1,6 @@ +/** + * The current unix time in seconds. + */ +export function nowInSeconds(): number { + return Math.floor(Date.now() / 1000) +} diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index c711b46bb..9d39f8ed7 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -16,6 +16,7 @@ import { import { oc } from '@orpc/contract' import { RequestValidationLinkPlugin, ResponseValidationLinkPlugin } from '@orpc/contract/plugins' import { EvlogHandlerPlugin } from '@orpc/evlog' +import { CacheHandlerPlugin } from '@orpc/experimental-cache' import { HibernationHandlerPlugin } from '@orpc/hibernation' import { SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin } from '@orpc/json-schema' import { BatchResponseCompressionHandlerPlugin, StaticFileHandlerPlugin, TmpFileUploadHandlerPlugin } from '@orpc/node' @@ -71,6 +72,7 @@ const router = implementer.router({ function createHandlerPlugins() { return [ + new CacheHandlerPlugin({ headers: ['cache-control'] }), new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin({ threshold: 0 }), new CORSHandlerPlugin(), From e9c806350738ba959d7ea02c42d8e5e7b079cf04 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 09:56:06 +0700 Subject: [PATCH 20/35] refactor(shared): split encodeCacheTag out of encodeCacheTagHeader --- packages/cloudflare/src/workers-cache.ts | 4 +-- packages/shared/src/http.test.ts | 35 +++++++++++++----------- packages/shared/src/http.ts | 28 +++++++++++++------ 3 files changed, 41 insertions(+), 26 deletions(-) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index 95cffec12..2b4911810 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -1,5 +1,5 @@ import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' -import { encodeCacheTagHeader, toArray } from '@orpc/shared' +import { encodeCacheTag, toArray } from '@orpc/shared' /** * The purge surface of Cloudflare Workers Caching, satisfied by both @@ -51,7 +51,7 @@ export class experimental_WorkersCacheStore implements CacheStore { async revalidate({ tags }: CacheRevalidateOptions): Promise { 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(tag => encodeCacheTagHeader([tag])), + tags: tags.map(tag => encodeCacheTag(tag)), }) if (!result.success) { diff --git a/packages/shared/src/http.test.ts b/packages/shared/src/http.test.ts index 05425d18f..371ab387d 100644 --- a/packages/shared/src/http.test.ts +++ b/packages/shared/src/http.test.ts @@ -1,5 +1,6 @@ import { decodeCacheTagHeader, + encodeCacheTag, encodeCacheTagHeader, isCompressibleContentType, isNoTransformCacheControl, @@ -11,6 +12,7 @@ import { pathToHttpPath, varyByAcceptEncoding, } from './http' +import { tryDecodeURIComponent } from './uri' describe('pathToHttpPath', () => { it('produces a leading slash', () => { @@ -251,7 +253,7 @@ describe('isNoTransformCacheControl', () => { }) }) -describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { +describe('encodeCacheTag', () => { it.each([ ['leaves plain tags alone', 'planets', 'planets'], ['leaves other printable ASCII alone', 'a1!~*\'()-_.:/?', 'a1!~*\'()-_.:/?'], @@ -264,35 +266,36 @@ describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { ['escapes non-ASCII as UTF-8', 'tiαΊΏng việt', 'ti%E1%BA%BFng%20vi%E1%BB%87t'], ['escapes astral characters as UTF-8', 'aπŸ˜€', 'a%F0%9F%98%80'], ])('%s', (_, tag, encoded) => { - expect(encodeCacheTagHeader([tag])).toBe(encoded) - expect(decodeCacheTagHeader(encoded)).toEqual([tag]) + expect(encodeCacheTag(tag)).toBe(encoded) + expect(tryDecodeURIComponent(encoded)).toBe(tag) + }) + + it('keeps case-folded tags distinct', () => { + expect(encodeCacheTag('Planets')).not.toBe(encodeCacheTag('planets')) + expect(encodeCacheTag('Planets').toLowerCase()).not.toBe(encodeCacheTag('planets').toLowerCase()) }) - it('joins tags with commas, and round-trips the whole list', () => { + it('encodes identically across calls, since the pattern is shared', () => { + expect(encodeCacheTag('A,B%C')).toBe(encodeCacheTag('A,B%C')) + expect(encodeCacheTag('A,B%C')).toBe('%41%2C%42%25%43') + }) +}) + +describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { + it('joins encoded tags with commas, and round-trips the list', () => { const tags = ['plain', 'a,b', '100%', 'CamelCase', 'tiαΊΏng việt', 'sp ace'] expect(encodeCacheTagHeader(['a', 'b'])).toBe('a,b') + expect(encodeCacheTagHeader(tags)).toBe(tags.map(tag => encodeCacheTag(tag)).join(',')) expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) }) - it('keeps case-folded tags distinct', () => { - expect(encodeCacheTagHeader(['Planets'])).not.toBe(encodeCacheTagHeader(['planets'])) - expect(encodeCacheTagHeader(['Planets']).toLowerCase()).not.toBe(encodeCacheTagHeader(['planets']).toLowerCase()) - }) - it('keeps empty tags instead of dropping them', () => { expect(encodeCacheTagHeader(['a', '', 'b'])).toBe('a,,b') expect(decodeCacheTagHeader('a,,b')).toEqual(['a', '', 'b']) expect(decodeCacheTagHeader('')).toEqual(['']) }) - it('encodes identically across calls, since the pattern is shared', () => { - const tags = ['A,B%C', 'tiαΊΏng việt'] - - expect(encodeCacheTagHeader(tags)).toBe(encodeCacheTagHeader(tags)) - expect(encodeCacheTagHeader(tags)).toBe('%41%2C%42%25%43,ti%E1%BA%BFng%20vi%E1%BB%87t') - }) - it('decodes malformed escapes as-is', () => { expect(decodeCacheTagHeader('%zz')).toEqual(['%zz']) }) diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 31d164535..3e2cf9998 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -142,21 +142,33 @@ export function isCompressibleContentType(contentType: string | null | undefined const UNSAFE_CACHE_TAG_CHARS = /[^\x21-\x7E]|[%,A-Z]/gu /** - * Encodes cache tags into a header value: tags are joined with commas, and - * only {@link UNSAFE_CACHE_TAG_CHARS} 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. + * Percent-encodes only {@link UNSAFE_CACHE_TAG_CHARS} in a cache tag, 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. Reach for this where tags travel + * one at a time, such as a purge API. * * @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( +export function encodeCacheTag(tag: string): string { + // encodeURIComponent emits the UTF-8 bytes a percent escape needs, but leaves + // the URI unreserved set alone, so the uppercase letters in it are escaped by + // hand. Those are single-byte ASCII, so the code point is the byte. + return tag.replace( UNSAFE_CACHE_TAG_CHARS, char => char >= 'A' && char <= 'Z' ? `%${char.charCodeAt(0).toString(16).toUpperCase()}` : encodeURIComponent(char), - )).join(',') + ) +} + +/** + * Joins {@link encodeCacheTag}-encoded tags with commas into a header value. + * + * @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 => encodeCacheTag(tag)).join(',') } /** From 8a12845b2bb00ca4b294f5b0e54e6ce136e903c3 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 10:22:19 +0700 Subject: [PATCH 21/35] feat!: add Upstash and Bun cache stores, take store clients positionally - RedisCacheStore and experimental_KVCacheStore take their client as the first argument - experimental_WorkersCacheStore defaults to the cache exported by cloudflare:workers - Redis, Upstash, and Bun stores share the shorter e:/t: key families and one envelope format - race-condition tests for every store and the cache middleware --- README.md | 4 +- apps/content/docs/api-reference.mdx | 4 +- apps/content/docs/helpers/cache.mdx | 90 +++++++--- packages/ai-sdk/README.md | 4 +- packages/arktype/README.md | 4 +- packages/bun/README.md | 4 +- packages/bun/package.json | 4 +- packages/bun/src/index.ts | 1 + packages/bun/src/redis-cache.test.ts | 170 ++++++++++++++++++ packages/bun/src/redis-cache.ts | 142 +++++++++++++++ packages/bun/tests/__shared__/utils.ts | 34 ++++ ...cache-redis-adapters-compatibility.test.ts | 66 +++++++ packages/cache/README.md | 2 +- packages/cache/package.json | 14 +- packages/cache/src/adapters/memory.test.ts | 35 ++++ packages/cache/src/adapters/redis.test.ts | 123 +++++++++---- packages/cache/src/adapters/redis.ts | 19 +- packages/cache/src/adapters/upstash.test.ts | 119 ++++++++++++ packages/cache/src/adapters/upstash.ts | 146 +++++++++++++++ packages/cache/src/middleware.test.ts | 34 ++++ .../tests/__shared__/store-contract.d.ts | 17 -- .../tests/__shared__/store-contract.d.ts.map | 1 - packages/cache/tests/__shared__/utils.ts | 33 ++++ packages/client/README.md | 4 +- packages/cloudflare/README.md | 4 +- packages/cloudflare/src/kv-cache.test.ts | 31 +++- packages/cloudflare/src/kv-cache.ts | 16 +- packages/cloudflare/src/workers-cache.test.ts | 21 ++- packages/cloudflare/src/workers-cache.ts | 17 +- packages/cloudflare/tests/__shared__/utils.ts | 33 ++++ packages/contract/README.md | 4 +- packages/effect/README.md | 4 +- packages/evlog/README.md | 4 +- packages/hibernation/README.md | 4 +- packages/json-schema/README.md | 4 +- packages/msw/README.md | 2 +- packages/nest/README.md | 4 +- packages/next/README.md | 4 +- packages/node/README.md | 4 +- packages/openapi/README.md | 4 +- packages/opentelemetry/README.md | 4 +- packages/pinia-colada/README.md | 4 +- packages/pino/README.md | 4 +- packages/publisher/README.md | 4 +- packages/ratelimit/README.md | 4 +- packages/server/README.md | 4 +- packages/shared/README.md | 4 +- packages/swr/README.md | 4 +- packages/tanstack-query/README.md | 4 +- packages/trpc/README.md | 4 +- packages/valibot/README.md | 4 +- packages/zod/README.md | 4 +- pnpm-lock.yaml | 22 ++- 53 files changed, 1110 insertions(+), 194 deletions(-) create mode 100644 packages/bun/src/redis-cache.test.ts create mode 100644 packages/bun/src/redis-cache.ts create mode 100644 packages/bun/tests/cache-redis-adapters-compatibility.test.ts create mode 100644 packages/cache/src/adapters/upstash.test.ts create mode 100644 packages/cache/src/adapters/upstash.ts delete mode 100644 packages/cache/tests/__shared__/store-contract.d.ts delete mode 100644 packages/cache/tests/__shared__/store-contract.d.ts.map create mode 100644 packages/cache/tests/__shared__/utils.ts create mode 100644 packages/cloudflare/tests/__shared__/utils.ts diff --git a/README.md b/README.md index 8215451a0..fb6bc025d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index 9f5d452eb..9c7c968fa 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/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/experimental-cache](https://npmx.dev/package-docs/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](/docs/helpers/cache) | | [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) | | [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/hibernation](https://npmx.dev/package-docs/@orpc/hibernation) | Leverage Hibernation APIs like Cloudflare's WebSocket Hibernation. | [Hibernation](/docs/integrations/hibernation) | @@ -51,7 +51,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | [@orpc/experimental-msw](https://npmx.dev/package-docs/@orpc/experimental-msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) | | [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | | [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher and Rate Limit. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | +| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Cache, Publisher, and Rate Limit. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index f7f33cad7..eac0526a9 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -34,11 +34,13 @@ An entry stays fresh for `ttl` seconds and is retained for an extra `swr` window ## 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) | +| Name | Adapter for | +| -------------------------------- | ------------------------------------------------------------------------------------------ | +| `MemoryCacheStore` | In-memory storage | +| `RedisCacheStore` | [Redis](https://github.com/redis/redis) | +| `UpstashCacheStore` | [Upstash Redis](https://github.com/upstash/redis-js) | +| `BunRedisCacheStore` | [Bun's Redis](https://bun.com/docs/runtime/redis) | +| `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | | `experimental_KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | | `experimental_WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | @@ -71,12 +73,55 @@ 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({ +const store = new RedisCacheStore(client, { /** - * The Redis client to store entries in. Connected lazily when needed. + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for cached outputs. + * + * @default RPCSerializer + */ + serializer: undefined, +}) +``` + +```ts upstash +import { UpstashCacheStore } from '@orpc/experimental-cache/upstash' +import { Redis } from '@upstash/redis' + +const redis = Redis.fromEnv() + +// Shares its key and envelope format with RedisCacheStore, +// so both can serve the same database. +const store = new UpstashCacheStore(redis, { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for cached outputs. + * + * @default RPCSerializer */ - redis: client, + serializer: undefined, +}) +``` + +```ts bun +import { BunRedisCacheStore } from '@orpc/bun' +import { redis } from 'bun' +// Shares its key and envelope format with RedisCacheStore, +// so both can serve the same database. +const store = new BunRedisCacheStore(redis, { /** * The prefix to use for Redis keys. * @@ -122,12 +167,7 @@ export default { async fetch(request, env) { // KV is eventually consistent: writes and revalidations may take // 60 seconds or more to be visible in other locations. - const store = new KVCacheStore({ - /** - * The KV namespace to store entries in. - */ - kv: env.CACHE_KV, - + const store = new KVCacheStore(env.CACHE_KV, { /** * The prefix to use for KV keys. * @@ -149,19 +189,17 @@ export default { ```ts cloudflare-workers-caching import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare' -export default { - async fetch(request, env, ctx) { - // Workers Caching caches whole responses in front of the Worker via the - // `cache-control` and `cache-tag` plugin headers; this store only purges - // tags on revalidation. Requires `"cache": { "enabled": true }` in your - // wrangler configuration. Purges are scoped to the calling entrypoint, - // tags match case-insensitively, and purge calls always use the Free - // tier rate limits regardless of your plan. - const store = new WorkersCacheStore({ cache: ctx.cache }) - }, -} +// Workers Caching caches whole responses in front of the Worker via the +// `cache-control` and `cache-tag` plugin headers; this store only purges +// tags on revalidation. Requires `"cache": { "enabled": true }` in your +// wrangler configuration. Purges are scoped to the calling entrypoint, +// tags match case-insensitively, and purge calls always use the Free +// tier rate limits regardless of your plan. +// +// Purges through `cache` from `cloudflare:workers` by default; pass +// a purger such as `ctx.cache` to use another one. +const store = new WorkersCacheStore() ``` - ## Cache Middleware diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index fa2588a1e..eda0c0fd5 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 4027515dc..3d054eb8d 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/bun/README.md b/packages/bun/README.md index eb3b8cf8a..86251e8ff 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -47,7 +47,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -64,7 +64,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/bun/package.json b/packages/bun/package.json index a16548d71..7589aead1 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -2,7 +2,7 @@ "name": "@orpc/bun", "type": "module", "version": "2.0.0-beta.32", - "description": "Bun integration for oRPC: Redis-backed pub/sub and rate limiting using Bun's built-in Redis client", + "description": "Bun integration for oRPC: Redis-backed pub/sub, rate limiting, and caching using Bun's built-in Redis client", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", @@ -20,6 +20,7 @@ "redis", "pubsub", "ratelimit", + "cache", "typescript" ], "sideEffects": false, @@ -48,6 +49,7 @@ }, "dependencies": { "@orpc/client": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", "@orpc/server": "workspace:*", diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index b5bc5396e..4b5a162d0 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -1,2 +1,3 @@ +export * from './redis-cache' export * from './redis-publisher' export * from './redis-ratelimit' diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts new file mode 100644 index 000000000..4a3ab8a23 --- /dev/null +++ b/packages/bun/src/redis-cache.test.ts @@ -0,0 +1,170 @@ +import { RPCSerializer } from '@orpc/client' +import { nowInSeconds, sleep } from '@orpc/shared' +import { RedisClient } from 'bun' +import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test' +import { holdResult } from '../tests/__shared__/utils' +import { BunRedisCacheStore } from './redis-cache' + +const REDIS_URL = Bun.env.REDIS_URL + +describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { + const redis = new RedisClient(REDIS_URL) + + beforeAll(async () => { + await redis.connect() + }) + + afterAll(() => { + redis.close() + }) + + function createTestingStore( + options: ConstructorParameters[1] = {}, + client: RedisClient = redis, + ) { + const prefix = `orpc-bun-redis-cache-store-${crypto.randomUUID()}:` + return { store: new BunRedisCacheStore(client, { prefix, ...options }), prefix } + } + + it('round-trips outputs with their tags and expiresAt, including undefined', async () => { + const { store } = createTestingStore() + + await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120 }) + + const entry = await store.get('k') + expect(entry!.output).toEqual({ nested: [1, 2] }) + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + + await store.set('u', undefined) + await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) + await expect(store.get('unknown')).resolves.toBeUndefined() + }) + + 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, + } + + await store.set('k', output) + await expect(store.get('k')).resolves.toMatchObject({ output }) + }) + + it('invalidates entries by any of their tags, and keeps ones set afterwards', async () => { + const { store } = createTestingStore() + + await store.set('multi', 'v', { tags: ['a', 'b'] }) + await store.set('other', 'v', { tags: ['c'] }) + + await store.revalidate({ tags: ['a', 'b'] }) + + await expect(store.get('multi')).resolves.toBeUndefined() + await expect(store.get('other')).resolves.toBeDefined() + + await store.set('multi', 'new', { tags: ['a'] }) + await expect(store.get('multi')).resolves.toMatchObject({ output: 'new' }) + }) + + it('supports a custom serializer', async () => { + const serializer = new RPCSerializer() + const serializeSpy = spyOn(serializer, 'serialize') + const deserializeSpy = spyOn(serializer, 'deserialize') + const { store } = createTestingStore({ serializer }) + + await store.set('k', { a: 1 }) + + await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).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: 1 }) + await store.set('swr', 'v', { ttl: 1, swr: 10 }) + + await sleep(1500) + + await expect(store.get('no-swr')).resolves.toBeUndefined() + + const stale = await store.get('swr') + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + }, { timeout: 20_000 }) + + it('stores entries and tag counters under the prefixed key families, defaulting to no prefix', async () => { + const { store, prefix } = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) + + await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(true) + await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(true) + + const unprefixed = new BunRedisCacheStore(redis) + const key = crypto.randomUUID() + + await unprefixed.set(key, 'v') + + await expect(redis.exists(`e:${key}`)).resolves.toBe(true) + await expect(unprefixed.get(key)).resolves.toMatchObject({ output: 'v' }) + }) + + it('treats tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createTestingStore() + + await redis.set(`${prefix}e:k`, JSON.stringify({ output: { json: 'v' }, tags: ['t'], tagVersions: {} })) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'v' }) + }) + + it('encodes non-string keys stably', async () => { + const { store } = createTestingStore() + + 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() + }) + + it('drops an entry whose tag versions were read before a racing revalidation', async () => { + const { client, release } = holdResult(redis, 'mget') + const { store, prefix } = createTestingStore({}, client) + + const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + await store.revalidate({ tags: ['t'] }) + release() + await set + + await expect(store.get('k')).resolves.toBeUndefined() + await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(false) + }) + + it('stays consistent under concurrent sets, gets, and a revalidation on a shared tag', async () => { + const { store } = createTestingStore() + const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) + + await Promise.all([ + ...keys.map(key => store.set(key, key, { tags: ['t'] })), + store.revalidate({ tags: ['t'] }), + ...keys.map(key => store.get(key)), + ]) + + // Entries snapshotted before the revalidation miss, the rest hit with their own output. + const entries = await Promise.all(keys.map(key => store.get(key))) + entries.forEach((entry, index) => { + if (entry !== undefined) { + expect(entry.output).toBe(keys[index]) + } + }) + + await store.revalidate({ tags: ['t'] }) + + await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) + }) +}) diff --git a/packages/bun/src/redis-cache.ts b/packages/bun/src/redis-cache.ts new file mode 100644 index 000000000..73b7407ae --- /dev/null +++ b/packages/bun/src/redis-cache.ts @@ -0,0 +1,142 @@ +import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import type { Public } from '@orpc/shared' +import type { RedisClient } from 'bun' +import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' +import { encodeCacheKey } from '@orpc/experimental-cache' +import { nowInSeconds, stringifyJSON } from '@orpc/shared' + +interface BunRedisCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: unknown + tags?: readonly string[] + /** + * Tag version counters snapshotted at set time. + */ + tagVersions?: Record + expiresAt?: number | undefined +} + +export interface BunRedisCacheStoreOptions { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix?: string + + /** + * Serializer for cached outputs. + * + * @default RPCSerializer + */ + serializer?: undefined | Public +} + +/** + * Cache store adapter for Bun's built-in Redis client with tag-based + * invalidation. Shares its key and envelope format with `RedisCacheStore`, + * so both can serve the same database. Entries are retained for `ttl + swr` + * via `EX` 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 BunRedisCacheStore implements CacheStore { + private readonly prefix: string + private readonly serializer: Public + + /** + * Key encoding has no serializer option, so one is built here rather than + * per call by {@link encodeCacheKey}. + */ + private readonly keySerializer = new RPCJsonSerializer() + + constructor( + private readonly redis: RedisClient, + options: BunRedisCacheStoreOptions = {}, + ) { + this.prefix = options.prefix ?? '' + this.serializer = options.serializer ?? new RPCSerializer() + } + + async get(key: unknown): Promise { + const entryKey = this.entryKey(key) + const raw = await this.redis.get(entryKey) + + if (raw === null) { + return undefined + } + + const envelope = JSON.parse(raw) as BunRedisCacheStoreEnvelope + + 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(entryKey) + return undefined + } + } + + return { + output: this.serializer.deserialize(envelope.output as any), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { + const serialized = this.serializer.serialize(output) + + const tags = options?.tags + + let tagVersions: Record | undefined + if (tags?.length) { + const versions = await this.redis.mget(...tags.map(tag => this.tagKey(tag))) + tagVersions = {} + tags.forEach((tag, index) => { + tagVersions![tag] = Number(versions[index] ?? 0) + }) + } + + const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + + const envelope: BunRedisCacheStoreEnvelope = { + output: serialized, + tags, + tagVersions, + expiresAt, + } + + const entryKey = this.entryKey(key) + const value = stringifyJSON(envelope) + + if (retention !== undefined) { + await this.redis.set(entryKey, value, 'EX', retention) + } + else { + await this.redis.set(entryKey, value) + } + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + // The client pipelines these into a single round trip. + await Promise.all(tags.map(tag => this.redis.incr(this.tagKey(tag)))) + } + + private entryKey(key: unknown): string { + return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` + } + + private tagKey(tag: string): string { + return `${this.prefix}t:${tag}` + } +} diff --git a/packages/bun/tests/__shared__/utils.ts b/packages/bun/tests/__shared__/utils.ts index a9bd8fd52..dc57b7d5b 100644 --- a/packages/bun/tests/__shared__/utils.ts +++ b/packages/bun/tests/__shared__/utils.ts @@ -13,3 +13,37 @@ export async function waitFor(fn: () => void, { timeout = 1000, interval = 50 } } throw lastError } + +/** + * Wraps a client so `method` still issues its underlying call immediately but + * only resolves once `release` is called. Run a racing operation before + * `release` to land it between that read and whatever follows it. + */ +export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + + const proxy = new Proxy(client, { + get(target, prop) { + const value = Reflect.get(target, prop) + + if (typeof value !== 'function') { + return value + } + + if (prop !== method) { + return value.bind(target) + } + + return async (...args: unknown[]) => { + const result = await value.apply(target, args) + await gate + return result + } + }, + }) + + return { client: proxy, release } +} diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts new file mode 100644 index 000000000..eec94b476 --- /dev/null +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -0,0 +1,66 @@ +import type { CacheStore } from '@orpc/experimental-cache' +import { RedisCacheStore } from '@orpc/experimental-cache/redis' +import { nowInSeconds } from '@orpc/shared' +import { RedisClient } from 'bun' +import { afterAll, describe, expect, it } from 'bun:test' +import { createClient } from 'redis' +import { BunRedisCacheStore } from '../src/redis-cache' + +const REDIS_URL = Bun.env.REDIS_URL + +describe.concurrent('cache redis adapters compatibility', async () => { + const stores: Array<{ name: string, store: CacheStore }> = [] + const prefix = `redis-adapters:${crypto.randomUUID()}:` + + if (REDIS_URL) { + const redis = createClient({ url: REDIS_URL }) + + afterAll(() => { + redis.close() + }) + + stores.push({ + name: 'redis', + store: new RedisCacheStore(redis, { prefix }), + }) + + const bunRedis = new RedisClient(REDIS_URL) + + afterAll(() => { + bunRedis.close() + }) + + stores.push({ + name: 'bun redis', + store: new BunRedisCacheStore(bunRedis, { prefix }), + }) + } + + describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { + for (const source of stores) { + for (const target of stores) { + if (source === target) { + continue + } + + it(`shares entries and revalidations: ${source.name} β†’ ${target.name}`, async () => { + const id = crypto.randomUUID() + const tag = `tag:${crypto.randomUUID()}` + const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } + + await source.store.set([['planet', 'find'], { b: 2, id }], output, { tags: [tag], ttl: 60 }) + + // Structurally equal keys resolve the same entry across adapters, whatever the property order. + const entry = await target.store.get([['planet', 'find'], { id, b: 2 }]) + expect(entry!.output).toEqual(output) + expect(entry!.tags).toEqual([tag]) + expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + + await target.store.revalidate({ tags: [tag] }) + + await expect(source.store.get([['planet', 'find'], { b: 2, id }])).resolves.toBeUndefined() + }) + } + } + }) +}) diff --git a/packages/cache/README.md b/packages/cache/README.md index 3419326d2..f861ad41a 100644 --- a/packages/cache/README.md +++ b/packages/cache/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/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-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, Upstash, 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 31ed2d814..f29ba447a 100644 --- a/packages/cache/package.json +++ b/packages/cache/package.json @@ -2,7 +2,7 @@ "name": "@orpc/experimental-cache", "type": "module", "version": "2.0.0-beta.32", - "description": "Tag-based caching and revalidation for oRPC procedures, with memory, Redis, and Vercel adapters", + "description": "Tag-based caching and revalidation for oRPC procedures, with memory, Redis, Upstash, and Vercel adapters", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", @@ -21,6 +21,7 @@ "revalidation", "stale-while-revalidate", "redis", + "upstash", "vercel", "middleware", "api", @@ -45,6 +46,11 @@ "import": "./dist/adapters/redis.mjs", "default": "./dist/adapters/redis.mjs" }, + "./upstash": { + "types": "./dist/adapters/upstash.d.mts", + "import": "./dist/adapters/upstash.mjs", + "default": "./dist/adapters/upstash.mjs" + }, "./vercel": { "types": "./dist/adapters/vercel.d.mts", "import": "./dist/adapters/vercel.mjs", @@ -57,6 +63,7 @@ ".": "./src/index.ts", "./memory": "./src/adapters/memory.ts", "./redis": "./src/adapters/redis.ts", + "./upstash": "./src/adapters/upstash.ts", "./vercel": "./src/adapters/vercel.ts" }, "files": [ @@ -67,10 +74,14 @@ "type:check": "tsc -b" }, "peerDependencies": { + "@upstash/redis": ">=1.35.6", "@vercel/functions": ">=2.1.0", "redis": ">=6.0.0" }, "peerDependenciesMeta": { + "@upstash/redis": { + "optional": true + }, "@vercel/functions": { "optional": true }, @@ -85,6 +96,7 @@ "@standardserver/core": "^0.8.2" }, "devDependencies": { + "@upstash/redis": "^1.38.2", "@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 index 319a3524f..95551bc32 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -85,3 +85,38 @@ describe('memoryCacheStore', () => { await expect(store.get('k')).resolves.toEqual({ output: 'new', tags: ['new'], expiresAt: undefined }) }) }) + +describe('memoryCacheStore concurrency', () => { + it('applies concurrent sets and revalidations in call order', async () => { + const store = new MemoryCacheStore() + + await Promise.all([ + store.set('before', 'v', { tags: ['t'] }), + store.revalidate({ tags: ['t'] }), + store.set('after', 'v', { tags: ['t'] }), + ]) + + await expect(store.get('before')).resolves.toBeUndefined() + await expect(store.get('after')).resolves.toMatchObject({ output: 'v' }) + }) + + it('misses consistently across concurrent gets of an invalidated entry', async () => { + const store = new MemoryCacheStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) + + await expect(Promise.all([store.get('k'), store.get('k'), store.get('k')])).resolves.toEqual([undefined, undefined, undefined]) + }) + + it('keeps the last of concurrent sets to the same key', async () => { + const store = new MemoryCacheStore() + + await Promise.all([ + store.set('k', 'first', { ttl: 1 }), + store.set('k', 'last', { tags: ['t'] }), + ]) + + await expect(store.get('k')).resolves.toEqual({ output: 'last', tags: ['t'], expiresAt: undefined }) + }) +}) diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index b00c140f5..5f78d246a 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -2,6 +2,7 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { createClient } from 'redis' import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { holdResult } from '../../tests/__shared__/utils' import { RedisCacheStore } from './redis' const REDIS_URL = process.env.REDIS_URL @@ -19,10 +20,10 @@ describe.concurrent('redis cache store integration', { }) function createTestingStore( - options: Partial[0]> = {}, + options: ConstructorParameters[1] = {}, ) { const prefix = `orpc-redis-cache-store-${crypto.randomUUID()}:` - return { store: new RedisCacheStore({ redis, prefix, ...options }), prefix } + return { store: new RedisCacheStore(redis, { prefix, ...options }), prefix } } describeCacheStoreContract(() => createTestingStore().store) @@ -61,13 +62,13 @@ describe.concurrent('redis cache store integration', { await store.set('k', 'v', { tags: ['t'] }) await store.revalidate({ tags: ['t'] }) - await expect(redis.exists(`${prefix}entry:k`)).resolves.toBe(1) - await expect(redis.exists(`${prefix}tag:t`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(1) }) it('lazily connects a closed client', async () => { const lazyRedis = createClient({ url: REDIS_URL }) - const store = new RedisCacheStore({ redis: lazyRedis, prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) + const store = new RedisCacheStore(lazyRedis, { prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) expect(lazyRedis.isOpen).toBe(false) await expect(store.get('unknown')).resolves.toBeUndefined() @@ -75,13 +76,52 @@ describe.concurrent('redis cache store integration', { await lazyRedis.destroy() }) + + it('stays consistent under concurrent sets, gets, and a revalidation on a shared tag', async () => { + const { store } = createTestingStore() + const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) + + await Promise.all([ + ...keys.map(key => store.set(key, key, { tags: ['t'] })), + store.revalidate({ tags: ['t'] }), + ...keys.map(key => store.get(key)), + ]) + + // Entries snapshotted before the revalidation miss, the rest hit with their own output. + const entries = await Promise.all(keys.map(key => store.get(key))) + entries.forEach((entry, index) => { + if (entry !== undefined) { + expect(entry.output).toBe(keys[index]) + } + }) + + await store.revalidate({ tags: ['t'] }) + + await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) + }) }) describe('redis cache store with a mocked client', () => { + /** + * A Map-backed stand-in for the commands the store uses, so tests can + * interleave real operations while still asserting the issued commands. + */ function createMockedRedis() { + const data = new Map() + const queued: string[] = [] + + const incr = (key: string) => { + const next = Number(data.get(key) ?? 0) + 1 + data.set(key, String(next)) + return next + } + const multi = { - incr: vi.fn(() => multi), - exec: vi.fn(async () => []), + incr: vi.fn((key: string) => { + queued.push(key) + return multi + }), + exec: vi.fn(async () => queued.splice(0).map(incr)), } const redis = { @@ -89,11 +129,14 @@ describe('redis cache store with a mocked client', () => { 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)[]> => []), + get: vi.fn(async (key: string): Promise => data.get(key) ?? null), + set: vi.fn(async (key: string, value: string, _options?: unknown) => { + data.set(key, value) + return 'OK' + }), + del: vi.fn(async (key: string) => (data.delete(key) ? 1 : 0)), + incr: vi.fn(async (key: string) => incr(key)), + mGet: vi.fn(async (keys: string[]): Promise<(string | null)[]> => keys.map(key => data.get(key) ?? null)), multi: vi.fn(() => multi), } @@ -102,7 +145,7 @@ describe('redis cache store with a mocked client', () => { function createMockedStore() { const { redis, multi } = createMockedRedis() - return { store: new RedisCacheStore({ redis: redis as any, prefix: 'p:' }), redis, multi } + return { store: new RedisCacheStore(redis as any, { prefix: 'p:' }), redis, multi } } it('misses on unknown keys without connecting an open client', async () => { @@ -110,7 +153,7 @@ describe('redis cache store with a mocked client', () => { await expect(store.get('k')).resolves.toBeUndefined() - expect(redis.get).toHaveBeenCalledWith('p:entry:k') + expect(redis.get).toHaveBeenCalledWith('p:e:k') expect(redis.connect).not.toHaveBeenCalled() }) @@ -129,9 +172,9 @@ describe('redis cache store with a mocked client', () => { await store.set('k', { a: 1 }, { tags: ['t'], ttl: 1, swr: 1 }) - expect(redis.mGet).toHaveBeenCalledWith(['p:tag:t']) + expect(redis.mGet).toHaveBeenCalledWith(['p:t:t']) expect(redis.set).toHaveBeenCalledWith( - 'p:entry:k', + 'p:e:k', expect.stringContaining('"tagVersions":{"t":2}'), { expiration: { type: 'EX', value: 2 } }, ) @@ -143,25 +186,24 @@ describe('redis cache store with a mocked client', () => { await store.set('k', 'v') expect(redis.mGet).not.toHaveBeenCalled() - expect(redis.set).toHaveBeenCalledWith('p:entry:k', expect.any(String), undefined) + expect(redis.set).toHaveBeenCalledWith('p:e:k', expect.any(String), undefined) }) 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: undefined, expiresAt: undefined }) expect(redis.mGet).not.toHaveBeenCalled() }) it('returns entries whose tag versions still match', async () => { - const { store, redis } = createMockedStore() - redis.mGet.mockResolvedValue(['2']) + const { store } = createMockedStore() + await store.revalidate({ tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) await store.set('k', 'v', { tags: ['t'], ttl: 1 }) - redis.get.mockResolvedValueOnce(redis.set.mock.calls[0]![1]) const entry = await store.get('k') expect(entry!.output).toBe('v') @@ -171,25 +213,38 @@ describe('redis cache store with a mocked client', () => { 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 store.revalidate({ tags: ['t'] }) + + await expect(store.get('k')).resolves.toBeUndefined() + expect(redis.del).toHaveBeenCalledWith('p:e:k') + }) + + it('drops an entry whose tag versions were read before a racing revalidation', async () => { + const { redis } = createMockedRedis() + const { client, release } = holdResult(redis, 'mGet') + const store = new RedisCacheStore(client as any, { prefix: 'p:' }) + + const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + await store.revalidate({ tags: ['t'] }) + release() + await set + expect(redis.set).toHaveBeenCalledWith('p:e:k', expect.stringContaining('"tagVersions":{"t":0}'), undefined) await expect(store.get('k')).resolves.toBeUndefined() - expect(redis.del).toHaveBeenCalledWith('p:entry:k') + expect(redis.del).toHaveBeenCalledWith('p:e:k') }) it('revalidates a single tag with one INCR, and many atomically', async () => { const { store, redis, multi } = createMockedStore() await store.revalidate({ tags: ['t'] }) - expect(redis.incr).toHaveBeenCalledWith('p:tag:t') + expect(redis.incr).toHaveBeenCalledWith('p:t:t') await store.revalidate({ tags: ['a', 'b'] }) - expect(multi.incr).toHaveBeenCalledWith('p:tag:a') - expect(multi.incr).toHaveBeenCalledWith('p:tag:b') + expect(multi.incr).toHaveBeenCalledWith('p:t:a') + expect(multi.incr).toHaveBeenCalledWith('p:t:b') expect(multi.exec).toHaveBeenCalledTimes(1) }) @@ -197,20 +252,17 @@ describe('redis cache store with a mocked client', () => { const serializer = new RPCSerializer() const serializeSpy = vi.spyOn(serializer, 'serialize') const { redis } = createMockedRedis() - const store = new RedisCacheStore({ redis: redis as any }) + const store = new RedisCacheStore(redis as any) - redis.mGet.mockResolvedValueOnce([null]) await store.set('k', 'v', { tags: ['t'], ttl: 1 }) expect(redis.set).toHaveBeenCalledWith( - 'entry:k', + 'e:k', expect.stringContaining('"tagVersions":{"t":0}'), { expiration: { type: 'EX', value: 1 } }, ) - 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 + const customStore = new RedisCacheStore(redis as any, { serializer }) await expect(customStore.get('k')).resolves.toMatchObject({ output: 'v' }) expect(serializeSpy).not.toHaveBeenCalled() // only used for writes and key encoding @@ -220,7 +272,6 @@ describe('redis cache store with a mocked client', () => { 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' }) }) @@ -232,6 +283,6 @@ describe('redis cache store with a mocked client', () => { 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:\[/) + expect(redis.get.mock.calls[0]![0]).toMatch(/^p:e:\[/) }) }) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index 6a41deec5..d8caab691 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -19,11 +19,6 @@ 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,7 +43,6 @@ export interface RedisCacheStoreOptions { * @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: Public @@ -58,8 +52,13 @@ export class RedisCacheStore implements CacheStore { */ private readonly keySerializer = new RPCJsonSerializer() - constructor(options: RedisCacheStoreOptions) { - this.redis = options.redis + /** + * @param redis The Redis client to store entries in. Connected lazily when needed. + */ + constructor( + private readonly redis: RedisClientType, + options: RedisCacheStoreOptions = {}, + ) { this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() } @@ -145,11 +144,11 @@ export class RedisCacheStore implements CacheStore { } private entryKey(key: unknown): string { - return `${this.prefix}entry:${encodeCacheKey(key, this.keySerializer)}` + return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` } private tagKey(tag: string): string { - return `${this.prefix}tag:${tag}` + return `${this.prefix}t:${tag}` } private async ensureConnection(): Promise { diff --git a/packages/cache/src/adapters/upstash.test.ts b/packages/cache/src/adapters/upstash.test.ts new file mode 100644 index 000000000..7ab31121b --- /dev/null +++ b/packages/cache/src/adapters/upstash.test.ts @@ -0,0 +1,119 @@ +import { RPCSerializer } from '@orpc/client' +import { nowInSeconds, sleep } from '@orpc/shared' +import { Redis } from '@upstash/redis' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { holdResult } from '../../tests/__shared__/utils' +import { UpstashCacheStore } from './upstash' + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN + +/** + * These tests depend on a real Upstash redis server β€” make sure to set the + * `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` envs. + */ +describe.concurrent('upstash cache store integration', { + skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, + timeout: 20_000, +}, () => { + const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + + function createTestingStore( + options: ConstructorParameters[1] = {}, + client: Redis = redis, + ) { + const prefix = `orpc-upstash-cache-store-${crypto.randomUUID()}:` + return { store: new UpstashCacheStore(client, { prefix, ...options }), prefix } + } + + describeCacheStoreContract(() => createTestingStore().store) + + it('supports a custom serializer', async () => { + 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(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).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: 1 }) + await store.set('swr', 'v', { ttl: 1, swr: 10 }) + + await sleep(1500) + + await expect(store.get('no-swr')).resolves.toBeUndefined() + + const stale = await store.get('swr') + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + }) + + it('stores entries and tag counters under the prefixed key families, defaulting to no prefix', async () => { + const { store, prefix } = createTestingStore() + + await store.set('k', 'v', { tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) + + await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(1) + + const unprefixed = new UpstashCacheStore(redis) + const key = crypto.randomUUID() + + await unprefixed.set(key, 'v') + + await expect(redis.exists(`e:${key}`)).resolves.toBe(1) + await expect(unprefixed.get(key)).resolves.toMatchObject({ output: 'v' }) + }) + + it('reads envelopes when the client does not parse JSON responses', async () => { + const rawRedis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + automaticDeserialization: false, + }) + const { store } = createTestingStore({}, rawRedis) + + await store.set('k', { a: 1 }, { tags: ['t'], ttl: 60 }) + + const entry = await store.get('k') + expect(entry!.output).toEqual({ a: 1 }) + expect(entry!.tags).toEqual(['t']) + expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + + await store.revalidate({ tags: ['t'] }) + await expect(store.get('k')).resolves.toBeUndefined() + }) + + it('treats tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createTestingStore() + + await redis.set(`${prefix}e:k`, JSON.stringify({ output: { json: 'v' }, tags: ['t'], tagVersions: {} })) + + await expect(store.get('k')).resolves.toMatchObject({ output: 'v' }) + }) + + it('drops an entry whose tag versions were read before a racing revalidation', async () => { + const { client, release } = holdResult(redis, 'mget') + const { store, prefix } = createTestingStore({}, client) + + const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + await store.revalidate({ tags: ['t'] }) + release() + await set + + await expect(store.get('k')).resolves.toBeUndefined() + await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(0) + }) +}) diff --git a/packages/cache/src/adapters/upstash.ts b/packages/cache/src/adapters/upstash.ts new file mode 100644 index 000000000..1d7354330 --- /dev/null +++ b/packages/cache/src/adapters/upstash.ts @@ -0,0 +1,146 @@ +import type { Public } from '@orpc/shared' +import type { Redis } from '@upstash/redis' +import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' +import { nowInSeconds, stringifyJSON } from '@orpc/shared' +import { encodeCacheKey } from '../utils' + +interface UpstashCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: unknown + tags?: readonly string[] + /** + * Tag version counters snapshotted at set time. + */ + tagVersions?: Record + expiresAt?: number | undefined +} + +export interface UpstashCacheStoreOptions { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix?: string + + /** + * Serializer for cached outputs. + * + * @default RPCSerializer + */ + serializer?: undefined | Public +} + +/** + * Cache store adapter for Upstash Redis with tag-based invalidation. Shares + * its key and envelope format with `RedisCacheStore`, so both can serve the + * same database. Entries are retained for `ttl + swr` via `EX` 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 UpstashCacheStore implements CacheStore { + private readonly prefix: string + private readonly serializer: Public + + /** + * Key encoding has no serializer option, so one is built here rather than + * per call by {@link encodeCacheKey}. + */ + private readonly keySerializer = new RPCJsonSerializer() + + constructor( + private readonly redis: Redis, + options: UpstashCacheStoreOptions = {}, + ) { + this.prefix = options.prefix ?? '' + this.serializer = options.serializer ?? new RPCSerializer() + } + + async get(key: unknown): Promise { + const entryKey = this.entryKey(key) + // The client parses JSON responses by default; a string means that is turned off. + const raw = await this.redis.get(entryKey) + + if (raw === null) { + return undefined + } + + const envelope = typeof raw === 'string' ? JSON.parse(raw) as UpstashCacheStoreEnvelope : raw + + if (envelope.tags?.length) { + const versions = await this.redis.mget<(string | number | null)[]>(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(entryKey) + return undefined + } + } + + return { + output: this.serializer.deserialize(envelope.output as any), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + } + } + + async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { + const serialized = this.serializer.serialize(output) + + const tags = options?.tags + + let tagVersions: Record | undefined + if (tags?.length) { + const versions = await this.redis.mget<(string | number | null)[]>(tags.map(tag => this.tagKey(tag))) + tagVersions = {} + tags.forEach((tag, index) => { + tagVersions![tag] = Number(versions[index] ?? 0) + }) + } + + const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + + const envelope: UpstashCacheStoreEnvelope = { + output: serialized, + tags, + tagVersions, + expiresAt, + } + + await this.redis.set( + this.entryKey(key), + stringifyJSON(envelope), + retention !== undefined ? { ex: retention } : undefined, + ) + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + if (tags.length === 1) { + await this.redis.incr(this.tagKey(tags[0])) + return + } + + const multi = this.redis.multi() + for (const tag of tags) { + multi.incr(this.tagKey(tag)) + } + await multi.exec() + } + + private entryKey(key: unknown): string { + return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` + } + + private tagKey(tag: string): string { + return `${this.prefix}t:${tag}` + } +} diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index 5398e3f34..ad1c45d4a 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -2,6 +2,7 @@ import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext, CacheEntry, CacheStore } from './types' import { call, os, type } from '@orpc/server' import { nowInSeconds } from '@orpc/shared' +import { MemoryCacheStore } from './adapters/memory' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' @@ -272,6 +273,39 @@ describe('cache', () => { expect(store.set).not.toHaveBeenCalled() }) }) + + describe('concurrency', () => { + it('runs the handler once per concurrent miss, then serves the stored output', async () => { + const store = new MemoryCacheStore() + const handlerFn = vi.fn(() => 'fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) + + await expect(Promise.all([run(), run()])).resolves.toEqual(['fresh', 'fresh']) + expect(handlerFn).toHaveBeenCalledTimes(2) // misses are not coalesced + + await expect(run()).resolves.toBe('fresh') + expect(handlerFn).toHaveBeenCalledTimes(2) + }) + + it('serves every concurrent stale hit immediately, refreshing once per hit', async () => { + const store = new MemoryCacheStore() + await store.set('k', 'stale', { ttl: 0, swr: 60 }) + + const handlerFn = vi.fn(() => 'fresh') + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k', ttl: 60 })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) + + await expect(Promise.all([run(), run()])).resolves.toEqual(['stale', 'stale']) + expect(handlerFn).toHaveBeenCalledTimes(2) // refreshes are not coalesced either + + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + + await expect(run()).resolves.toBe('fresh') + expect(handlerFn).toHaveBeenCalledTimes(2) + }) + }) }) describe('revalidate', () => { diff --git a/packages/cache/tests/__shared__/store-contract.d.ts b/packages/cache/tests/__shared__/store-contract.d.ts deleted file mode 100644 index 9880b2ca6..000000000 --- a/packages/cache/tests/__shared__/store-contract.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { CacheStore } from '../../src' - -export interface CacheStoreContractOptions { - /** - * Whether the store serializes outputs, and so drops values it cannot - * encode instead of storing them. - * - * @default false - */ - serializes?: boolean -} -/** - * The behavior every {@link CacheStore} must share, run against one adapter. - * Adapter suites keep only what is specific to their backend. - */ -export declare function describeCacheStoreContract(createStore: () => CacheStore, options?: CacheStoreContractOptions): void -// # sourceMappingURL=store-contract.d.ts.map diff --git a/packages/cache/tests/__shared__/store-contract.d.ts.map b/packages/cache/tests/__shared__/store-contract.d.ts.map deleted file mode 100644 index a4b989527..000000000 --- a/packages/cache/tests/__shared__/store-contract.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"store-contract.d.ts","sourceRoot":"","sources":["store-contract.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,WAAW,CAAA;AAG3C,MAAM,WAAW,yBAAyB;IACxC;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,MAAM,UAAU,EAC7B,OAAO,GAAE,yBAA8B,GACtC,IAAI,CA0EN"} \ No newline at end of file diff --git a/packages/cache/tests/__shared__/utils.ts b/packages/cache/tests/__shared__/utils.ts new file mode 100644 index 000000000..1354ec6e0 --- /dev/null +++ b/packages/cache/tests/__shared__/utils.ts @@ -0,0 +1,33 @@ +/** + * Wraps a client so `method` still issues its underlying call immediately but + * only resolves once `release` is called. Run a racing operation before + * `release` to land it between that read and whatever follows it. + */ +export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + + const proxy = new Proxy(client, { + get(target, prop) { + const value = Reflect.get(target, prop) + + if (typeof value !== 'function') { + return value + } + + if (prop !== method) { + return value.bind(target) + } + + return async (...args: unknown[]) => { + const result = await value.apply(target, args) + await gate + return result + } + }, + }) + + return { client: proxy, release } +} diff --git a/packages/client/README.md b/packages/client/README.md index 8215451a0..fb6bc025d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 731316db4..8e195888a 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index eca27a0f7..dbc6b88c3 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -3,12 +3,13 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds } from '@orpc/shared' import { env } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' +import { holdResult } from '../tests/__shared__/utils' import { experimental_KVCacheStore } from './kv-cache' describe('experimental_KVCacheStore', () => { function createTestingStore(options: Partial = {}) { const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - return { store: new experimental_KVCacheStore({ kv: env.CACHE_KV, prefix, ...options }), prefix } + return { store: new experimental_KVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } } // The cross-package tsconfig rootDir keeps the shared store contract out of @@ -81,24 +82,24 @@ describe('experimental_KVCacheStore', () => { evictAt, }) - await env.CACHE_KV.put(`${prefix}entry:stale`, envelope(nowInSeconds() - 1, nowInSeconds() + 60)) - await env.CACHE_KV.put(`${prefix}entry:evicted`, envelope(nowInSeconds() - 2, nowInSeconds() - 1)) + await env.CACHE_KV.put(`${prefix}e:stale`, envelope(nowInSeconds() - 1, nowInSeconds() + 60)) + await env.CACHE_KV.put(`${prefix}e:evicted`, envelope(nowInSeconds() - 2, nowInSeconds() - 1)) const stale = await store.get('stale') expect(stale!.output).toBe('v') expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) await expect(store.get('evicted')).resolves.toBeUndefined() - await expect(env.CACHE_KV.get(`${prefix}entry:evicted`)).resolves.toBeNull() + await expect(env.CACHE_KV.get(`${prefix}e:evicted`)).resolves.toBeNull() }) it('defaults to no prefix', async () => { - const store = new experimental_KVCacheStore({ kv: env.CACHE_KV }) + const store = new experimental_KVCacheStore(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(env.CACHE_KV.get(`e:${key}`)).resolves.toBeTypeOf('string') await expect(store.get(key)).resolves.toMatchObject({ output: 'v' }) }) @@ -108,7 +109,21 @@ describe('experimental_KVCacheStore', () => { await store.set('k', 'v', { tags: ['t'] }) await store.revalidate({ tags: ['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') + await expect(env.CACHE_KV.get(`${prefix}e:k`)).resolves.toBeTypeOf('string') + await expect(env.CACHE_KV.get(`${prefix}t:t`)).resolves.toBeTypeOf('string') + }) + + it('drops an entry whose tag tokens were read before a racing revalidation', async () => { + const { client: kv, release } = holdResult(env.CACHE_KV, 'get') + const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` + const store = new experimental_KVCacheStore(kv, { prefix }) + + const set = store.set('k', 'v', { tags: ['t'] }) // tokens read now, entry written after release + await store.revalidate({ tags: ['t'] }) + release() + await set + + await expect(store.get('k')).resolves.toBeUndefined() + await expect(env.CACHE_KV.get(`${prefix}e:k`)).resolves.toBeNull() }) }) diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts index b2438632e..cac253ad2 100644 --- a/packages/cloudflare/src/kv-cache.ts +++ b/packages/cloudflare/src/kv-cache.ts @@ -21,11 +21,6 @@ interface KVCacheStoreEnvelope { } export interface experimental_KVCacheStoreOptions { - /** - * The KV namespace to store entries in. - */ - kv: KVNamespace - /** * The prefix to use for KV keys. * @@ -56,7 +51,6 @@ export interface experimental_KVCacheStoreOptions { * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class experimental_KVCacheStore implements CacheStore { - private readonly kv: KVNamespace private readonly prefix: string private readonly serializer: Public @@ -66,8 +60,10 @@ export class experimental_KVCacheStore implements CacheStore { */ private readonly keySerializer = new RPCJsonSerializer() - constructor(options: experimental_KVCacheStoreOptions) { - this.kv = options.kv + constructor( + private readonly kv: KVNamespace, + options: experimental_KVCacheStoreOptions = {}, + ) { this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() } @@ -144,10 +140,10 @@ export class experimental_KVCacheStore implements CacheStore { } private entryKey(key: unknown): string { - return `${this.prefix}entry:${encodeCacheKey(key, this.keySerializer)}` + return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` } private tagKey(tag: string): string { - return `${this.prefix}tag:${tag}` + return `${this.prefix}t:${tag}` } } diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 647901a7a..a3b3630fc 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -1,3 +1,4 @@ +import { cache } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' import { experimental_WorkersCacheStore } from './workers-cache' @@ -8,7 +9,7 @@ describe('experimental_WorkersCacheStore', () => { it('always misses and stores nothing', async () => { const purger = createPurger() - const store = new experimental_WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore(purger) await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) await expect(store.get('k')).resolves.toBeUndefined() @@ -17,7 +18,7 @@ describe('experimental_WorkersCacheStore', () => { it('purges encoded tags through workers caching', async () => { const purger = createPurger() - const store = new experimental_WorkersCacheStore({ cache: purger }) + const store = new experimental_WorkersCacheStore(purger) await store.revalidate({ tags: ['planets', 'a,b'] }) @@ -25,11 +26,16 @@ describe('experimental_WorkersCacheStore', () => { expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets', 'a%2Cb'] }) }) + it('defaults to the cache exported by cloudflare:workers', () => { + const store = new experimental_WorkersCacheStore() + + expect((store as any).cache).toBe(cache) + }) + it('throws a bare error when the purge fails without messages', async () => { - const purger = { + const store = new experimental_WorkersCacheStore({ purge: vi.fn(async () => ({ success: false })), - } - const store = new experimental_WorkersCacheStore({ cache: purger }) + }) await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( 'experimental_WorkersCacheStore failed to purge tags', @@ -37,10 +43,9 @@ describe('experimental_WorkersCacheStore', () => { }) it('throws when the purge fails, including error messages', async () => { - const purger = { + const store = new experimental_WorkersCacheStore({ purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), - } - const store = new experimental_WorkersCacheStore({ cache: purger }) + }) await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( '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 2b4911810..13fbd125f 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -1,5 +1,6 @@ import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' import { encodeCacheTag, toArray } from '@orpc/shared' +import * as workers from 'cloudflare:workers' /** * The purge surface of Cloudflare Workers Caching, satisfied by both @@ -11,14 +12,6 @@ export interface experimental_WorkersCachePurger { purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> } -export interface experimental_WorkersCacheStoreOptions { - /** - * The Workers Caching purge surface: `ctx.cache` or `cache` imported - * from `cloudflare:workers`. - */ - cache: experimental_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 @@ -36,8 +29,12 @@ export interface experimental_WorkersCacheStoreOptions { export class experimental_WorkersCacheStore implements CacheStore { private readonly cache: experimental_WorkersCachePurger - constructor(options: experimental_WorkersCacheStoreOptions) { - this.cache = options.cache + /** + * @param cache The Workers Caching purge surface, such as `ctx.cache`. + * Defaults to `cache` imported from `cloudflare:workers`. + */ + constructor(cache?: experimental_WorkersCachePurger) { + this.cache = cache ?? workers.cache } async get(_key: unknown): Promise { diff --git a/packages/cloudflare/tests/__shared__/utils.ts b/packages/cloudflare/tests/__shared__/utils.ts new file mode 100644 index 000000000..1354ec6e0 --- /dev/null +++ b/packages/cloudflare/tests/__shared__/utils.ts @@ -0,0 +1,33 @@ +/** + * Wraps a client so `method` still issues its underlying call immediately but + * only resolves once `release` is called. Run a racing operation before + * `release` to land it between that read and whatever follows it. + */ +export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + + const proxy = new Proxy(client, { + get(target, prop) { + const value = Reflect.get(target, prop) + + if (typeof value !== 'function') { + return value + } + + if (prop !== method) { + return value.bind(target) + } + + return async (...args: unknown[]) => { + const result = await value.apply(target, args) + await gate + return result + } + }, + }) + + return { client: proxy, release } +} diff --git a/packages/contract/README.md b/packages/contract/README.md index ba00a00f4..64727847e 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/effect/README.md b/packages/effect/README.md index 14bb19055..4e6a5b632 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/evlog/README.md b/packages/evlog/README.md index d4fb702b6..627fb622c 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 7da5118db..78f00e96d 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 53eb49bc8..cadf9a305 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/msw/README.md b/packages/msw/README.md index aee1c5151..b855f26a0 100644 --- a/packages/msw/README.md +++ b/packages/msw/README.md @@ -66,7 +66,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/nest/README.md b/packages/nest/README.md index d68cfe570..cb1ad1e51 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/next/README.md b/packages/next/README.md index 290e7f966..4bb484d55 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/node/README.md b/packages/node/README.md index 7a4af2b41..a14253d87 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/openapi/README.md b/packages/openapi/README.md index ebf63c7c7..99a0f950c 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index bd1d0873c..15f3784d8 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 7f464930d..56f4f16c1 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/pino/README.md b/packages/pino/README.md index dc6043fac..cb48bd46e 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 580e9adbc..094589f6a 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 71d8c9c22..b311e2f96 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/server/README.md b/packages/server/README.md index 4fe04df91..5b62384e7 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/shared/README.md b/packages/shared/README.md index 3393dfb4d..404dbe276 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/swr/README.md b/packages/swr/README.md index ce8efe0d2..490ece085 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 0c2cf2a09..4901d0601 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/trpc/README.md b/packages/trpc/README.md index 8ca01950f..595692fe0 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/valibot/README.md b/packages/valibot/README.md index c3fcdbd58..42206234f 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/packages/zod/README.md b/packages/zod/README.md index 64153b6dd..d1adf26e5 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -50,7 +50,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. | [Publisher](https://orpc.dev/docs/helpers/publisher) | | [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | | [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | | [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | @@ -67,7 +67,7 @@ You can read the documentation [here](https://orpc.dev). | [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | | [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | | [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | | [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8086534d5..c3006d6b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -426,6 +426,9 @@ importers: '@orpc/client': specifier: workspace:* version: link:../client + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../cache '@orpc/publisher': specifier: workspace:* version: link:../publisher @@ -464,6 +467,9 @@ importers: specifier: ^0.8.2 version: 0.8.2 devDependencies: + '@upstash/redis': + specifier: ^1.38.2 + version: 1.38.3 '@vercel/functions': specifier: ^3.9.5 version: 3.9.5(ws@8.21.3) @@ -16007,7 +16013,7 @@ snapshots: dependencies: quansync: 1.0.0 - '@redis/bloom@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/bloom@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) @@ -16018,15 +16024,15 @@ snapshots: '@node-rs/xxhash': 1.7.7 '@opentelemetry/api': 1.9.1 - '@redis/json@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/json@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/search@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/search@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/time-series@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/time-series@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) @@ -23735,11 +23741,11 @@ snapshots: redis@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1): dependencies: - '@redis/bloom': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/bloom': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/json': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) - '@redis/search': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) - '@redis/time-series': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/json': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) + '@redis/search': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) + '@redis/time-series': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) transitivePeerDependencies: - '@node-rs/xxhash' - '@opentelemetry/api' From 49afa3d463366b56ad7a76d0a492f6bcbb5f2bcc Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 10:50:35 +0700 Subject: [PATCH 22/35] test(cache): add cross-adapter compatibility suites for the Redis stores - Redis and Upstash stores share one Upstash database over rediss:// and REST - Bun and Redis stores share REDIS_URL - race tests wait for the held read to complete before the racing revalidation --- packages/bun/src/redis-cache.test.ts | 5 +- packages/bun/tests/__shared__/utils.ts | 15 ++- ...cache-redis-adapters-compatibility.test.ts | 53 ++++++++-- packages/cache/src/adapters/redis.test.ts | 5 +- packages/cache/src/adapters/upstash.test.ts | 8 +- packages/cache/tests/__shared__/utils.ts | 15 ++- .../redis-adapters-compatibility.test.ts | 99 +++++++++++++++++++ packages/cloudflare/src/kv-cache.test.ts | 5 +- packages/cloudflare/tests/__shared__/utils.ts | 15 ++- 9 files changed, 189 insertions(+), 31 deletions(-) create mode 100644 packages/cache/tests/redis-adapters-compatibility.test.ts diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts index 4a3ab8a23..66e361883 100644 --- a/packages/bun/src/redis-cache.test.ts +++ b/packages/bun/src/redis-cache.test.ts @@ -133,10 +133,11 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { }) it('drops an entry whose tag versions were read before a racing revalidation', async () => { - const { client, release } = holdResult(redis, 'mget') + const { client, read, release } = holdResult(redis, 'mget') const { store, prefix } = createTestingStore({}, client) - const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release + await read // versions are read by now await store.revalidate({ tags: ['t'] }) release() await set diff --git a/packages/bun/tests/__shared__/utils.ts b/packages/bun/tests/__shared__/utils.ts index dc57b7d5b..2c99f9472 100644 --- a/packages/bun/tests/__shared__/utils.ts +++ b/packages/bun/tests/__shared__/utils.ts @@ -16,15 +16,21 @@ export async function waitFor(fn: () => void, { timeout = 1000, interval = 50 } /** * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Run a racing operation before - * `release` to land it between that read and whatever follows it. + * only resolves once `release` is called. Await `read` to know the first held + * call has completed, then run a racing operation before `release` to land it + * between that read and whatever follows it. */ -export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { +export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) + let settle!: () => void + const read = new Promise((resolve) => { + settle = resolve + }) + const proxy = new Proxy(client, { get(target, prop) { const value = Reflect.get(target, prop) @@ -39,11 +45,12 @@ export function holdResult(client: T, method: keyof T & string return async (...args: unknown[]) => { const result = await value.apply(target, args) + settle() await gate return result } }, }) - return { client: proxy, release } + return { client: proxy, read, release } } diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts index eec94b476..c93e449a4 100644 --- a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -1,6 +1,6 @@ import type { CacheStore } from '@orpc/experimental-cache' import { RedisCacheStore } from '@orpc/experimental-cache/redis' -import { nowInSeconds } from '@orpc/shared' +import { nowInSeconds, sleep } from '@orpc/shared' import { RedisClient } from 'bun' import { afterAll, describe, expect, it } from 'bun:test' import { createClient } from 'redis' @@ -8,6 +8,13 @@ import { BunRedisCacheStore } from '../src/redis-cache' const REDIS_URL = Bun.env.REDIS_URL +/** + * These tests require a real Redis server. Set `REDIS_URL` before running them. + * + * When adding new tests, always use unique keys to avoid conflicts with other cases. + * + * All adapters must connect to the same server. + */ describe.concurrent('cache redis adapters compatibility', async () => { const stores: Array<{ name: string, store: CacheStore }> = [] const prefix = `redis-adapters:${crypto.randomUUID()}:` @@ -19,10 +26,7 @@ describe.concurrent('cache redis adapters compatibility', async () => { redis.close() }) - stores.push({ - name: 'redis', - store: new RedisCacheStore(redis, { prefix }), - }) + stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix }) }) const bunRedis = new RedisClient(REDIS_URL) @@ -30,10 +34,7 @@ describe.concurrent('cache redis adapters compatibility', async () => { bunRedis.close() }) - stores.push({ - name: 'bun redis', - store: new BunRedisCacheStore(bunRedis, { prefix }), - }) + stores.push({ name: 'bun redis', store: new BunRedisCacheStore(bunRedis, { prefix }) }) } describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { @@ -59,7 +60,39 @@ describe.concurrent('cache redis adapters compatibility', async () => { await target.store.revalidate({ tags: [tag] }) await expect(source.store.get([['planet', 'find'], { b: 2, id }])).resolves.toBeUndefined() - }) + }, { timeout: 20_000 }) + + it(`shares tag counters: ${source.name} β†’ ${target.name}`, async () => { + const key = `counter:${crypto.randomUUID()}` + const tag = `tag:${crypto.randomUUID()}` + + await source.store.set(key, 'v1', { tags: [tag] }) + await target.store.revalidate({ tags: [tag] }) + await expect(source.store.get(key)).resolves.toBeUndefined() + + // A snapshot taken by one adapter stays valid for the other until the next revalidation. + await target.store.set(key, 'v2', { tags: [tag] }) + await expect(source.store.get(key)).resolves.toMatchObject({ output: 'v2' }) + + await source.store.revalidate({ tags: [tag] }) + await expect(target.store.get(key)).resolves.toBeUndefined() + }, { timeout: 20_000 }) + + it(`shares retention: ${source.name} β†’ ${target.name}`, async () => { + const noSwr = `no-swr:${crypto.randomUUID()}` + const swr = `swr:${crypto.randomUUID()}` + + await source.store.set(noSwr, 'v', { ttl: 1 }) + await source.store.set(swr, 'v', { ttl: 1, swr: 10 }) + + await sleep(1500) + + await expect(target.store.get(noSwr)).resolves.toBeUndefined() + + const stale = await target.store.get(swr) + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + }, { timeout: 20_000 }) } } }) diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 5f78d246a..4fdf4678d 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -223,10 +223,11 @@ describe('redis cache store with a mocked client', () => { it('drops an entry whose tag versions were read before a racing revalidation', async () => { const { redis } = createMockedRedis() - const { client, release } = holdResult(redis, 'mGet') + const { client, read, release } = holdResult(redis, 'mGet') const store = new RedisCacheStore(client as any, { prefix: 'p:' }) - const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release + await read // versions are read by now await store.revalidate({ tags: ['t'] }) release() await set diff --git a/packages/cache/src/adapters/upstash.test.ts b/packages/cache/src/adapters/upstash.test.ts index 7ab31121b..5c974bec9 100644 --- a/packages/cache/src/adapters/upstash.test.ts +++ b/packages/cache/src/adapters/upstash.test.ts @@ -13,7 +13,8 @@ const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN * `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` envs. */ describe.concurrent('upstash cache store integration', { - skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN, + // TODO: Upstash is not compatible with Node 26 yet β€” temporarily disable these tests and revisit in the future. + skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN || process.versions.node.startsWith('26.'), timeout: 20_000, }, () => { const redis = new Redis({ @@ -105,10 +106,11 @@ describe.concurrent('upstash cache store integration', { }) it('drops an entry whose tag versions were read before a racing revalidation', async () => { - const { client, release } = holdResult(redis, 'mget') + const { client, read, release } = holdResult(redis, 'mget') const { store, prefix } = createTestingStore({}, client) - const set = store.set('k', 'v', { tags: ['t'] }) // versions read now, entry written after release + const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release + await read // versions are read by now await store.revalidate({ tags: ['t'] }) release() await set diff --git a/packages/cache/tests/__shared__/utils.ts b/packages/cache/tests/__shared__/utils.ts index 1354ec6e0..c82d9f0d3 100644 --- a/packages/cache/tests/__shared__/utils.ts +++ b/packages/cache/tests/__shared__/utils.ts @@ -1,14 +1,20 @@ /** * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Run a racing operation before - * `release` to land it between that read and whatever follows it. + * only resolves once `release` is called. Await `read` to know the first held + * call has completed, then run a racing operation before `release` to land it + * between that read and whatever follows it. */ -export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { +export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) + let settle!: () => void + const read = new Promise((resolve) => { + settle = resolve + }) + const proxy = new Proxy(client, { get(target, prop) { const value = Reflect.get(target, prop) @@ -23,11 +29,12 @@ export function holdResult(client: T, method: keyof T & string return async (...args: unknown[]) => { const result = await value.apply(target, args) + settle() await gate return result } }, }) - return { client: proxy, release } + return { client: proxy, read, release } } diff --git a/packages/cache/tests/redis-adapters-compatibility.test.ts b/packages/cache/tests/redis-adapters-compatibility.test.ts new file mode 100644 index 000000000..bf6ec18e1 --- /dev/null +++ b/packages/cache/tests/redis-adapters-compatibility.test.ts @@ -0,0 +1,99 @@ +import type { CacheStore } from '../src' +import { nowInSeconds, sleep } from '@orpc/shared' +import { Redis } from '@upstash/redis' +import { createClient } from 'redis' +import { RedisCacheStore } from '../src/adapters/redis' +import { UpstashCacheStore } from '../src/adapters/upstash' + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN + +/** + * These tests require a real Upstash Redis server. + * Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` before running them. + * + * When adding new tests, always use unique keys to avoid conflicts with other cases. + * + * All adapters must connect to the same server. + */ +describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, () => { + const stores: Array<{ name: string, store: CacheStore }> = [] + const prefix = `redis-adapters:${crypto.randomUUID()}:` + + if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN) { + const redis = createClient({ url: `rediss://default:${UPSTASH_REDIS_REST_TOKEN}@${new URL(UPSTASH_REDIS_REST_URL).host}:6379` }) + + afterAll(() => { + redis.close() + }) + + stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix }) }) + } + + // TODO: Upstash is not compatible with Node 26 yet β€” temporarily disable these tests and revisit in the future. + if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN && !process.versions.node.startsWith('26.')) { + const upstashRedis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }) + + stores.push({ name: 'upstash', store: new UpstashCacheStore(upstashRedis, { prefix }) }) + } + + describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { + for (const source of stores) { + for (const target of stores) { + if (source === target) { + continue + } + + it(`shares entries and revalidations: ${source.name} β†’ ${target.name}`, async () => { + const id = crypto.randomUUID() + const tag = `tag:${crypto.randomUUID()}` + const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } + + await source.store.set([['planet', 'find'], { b: 2, id }], output, { tags: [tag], ttl: 60 }) + + // Structurally equal keys resolve the same entry across adapters, whatever the property order. + const entry = await target.store.get([['planet', 'find'], { id, b: 2 }]) + expect(entry!.output).toEqual(output) + expect(entry!.tags).toEqual([tag]) + expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + + await target.store.revalidate({ tags: [tag] }) + + await expect(source.store.get([['planet', 'find'], { b: 2, id }])).resolves.toBeUndefined() + }) + + it(`shares tag counters: ${source.name} β†’ ${target.name}`, async () => { + const key = `counter:${crypto.randomUUID()}` + const tag = `tag:${crypto.randomUUID()}` + + await source.store.set(key, 'v1', { tags: [tag] }) + await target.store.revalidate({ tags: [tag] }) + await expect(source.store.get(key)).resolves.toBeUndefined() + + // A snapshot taken by one adapter stays valid for the other until the next revalidation. + await target.store.set(key, 'v2', { tags: [tag] }) + await expect(source.store.get(key)).resolves.toMatchObject({ output: 'v2' }) + + await source.store.revalidate({ tags: [tag] }) + await expect(target.store.get(key)).resolves.toBeUndefined() + }) + + it(`shares retention: ${source.name} β†’ ${target.name}`, async () => { + const noSwr = `no-swr:${crypto.randomUUID()}` + const swr = `swr:${crypto.randomUUID()}` + + await source.store.set(noSwr, 'v', { ttl: 1 }) + await source.store.set(swr, 'v', { ttl: 1, swr: 10 }) + + await sleep(1500) + + await expect(target.store.get(noSwr)).resolves.toBeUndefined() + + const stale = await target.store.get(swr) + expect(stale!.output).toBe('v') + expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + }) + } + } + }) +}) diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts index dbc6b88c3..10f060dde 100644 --- a/packages/cloudflare/src/kv-cache.test.ts +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -114,11 +114,12 @@ describe('experimental_KVCacheStore', () => { }) it('drops an entry whose tag tokens were read before a racing revalidation', async () => { - const { client: kv, release } = holdResult(env.CACHE_KV, 'get') + const { client: kv, read, release } = holdResult(env.CACHE_KV, 'get') const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` const store = new experimental_KVCacheStore(kv, { prefix }) - const set = store.set('k', 'v', { tags: ['t'] }) // tokens read now, entry written after release + const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release + await read // tokens are read by now await store.revalidate({ tags: ['t'] }) release() await set diff --git a/packages/cloudflare/tests/__shared__/utils.ts b/packages/cloudflare/tests/__shared__/utils.ts index 1354ec6e0..c82d9f0d3 100644 --- a/packages/cloudflare/tests/__shared__/utils.ts +++ b/packages/cloudflare/tests/__shared__/utils.ts @@ -1,14 +1,20 @@ /** * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Run a racing operation before - * `release` to land it between that read and whatever follows it. + * only resolves once `release` is called. Await `read` to know the first held + * call has completed, then run a racing operation before `release` to land it + * between that read and whatever follows it. */ -export function holdResult(client: T, method: keyof T & string): { client: T, release: () => void } { +export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { let release!: () => void const gate = new Promise((resolve) => { release = resolve }) + let settle!: () => void + const read = new Promise((resolve) => { + settle = resolve + }) + const proxy = new Proxy(client, { get(target, prop) { const value = Reflect.get(target, prop) @@ -23,11 +29,12 @@ export function holdResult(client: T, method: keyof T & string return async (...args: unknown[]) => { const result = await value.apply(target, args) + settle() await gate return result } }, }) - return { client: proxy, release } + return { client: proxy, read, release } } From 1d9265f62a65c73398263961f0dbcb51158be983 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 14:57:42 +0700 Subject: [PATCH 23/35] docs: sync the packages tables across every package README --- packages/cache/README.md | 64 +++++++++++++++++++++++----------------- packages/msw/README.md | 41 ++++++++++++------------- 2 files changed, 58 insertions(+), 47 deletions(-) diff --git a/packages/cache/README.md b/packages/cache/README.md index f861ad41a..f685538c8 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -29,45 +29,55 @@ You can read the documentation [here](https://orpc.dev). **Core** -- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define API contract as the single source of truth. -- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build APIs or implement contracts. -- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume APIs with end-to-end type safety. -- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Add OpenAPI compatibility to APIs. +| Package | Purpose | Docs | +| -------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/contract](https://www.npmjs.com/package/@orpc/contract) | Define API contract as the single source of truth. | [Procedure Contract](https://orpc.dev/docs/contract/procedure), [Contract Router](https://orpc.dev/docs/contract/router), [Implementation](https://orpc.dev/docs/contract/implementation), [Generate from OpenAPI](https://orpc.dev/docs/contract/generate-from-openapi) | +| [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | +| [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | +| [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | **Schema validation** -- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). -- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). -- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). +| Package | Purpose | Docs | +| ------------------------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------- | +| [@orpc/zod](https://www.npmjs.com/package/@orpc/zod) | Integrate with [Zod](https://zod.dev/). | [Zod](https://orpc.dev/docs/integrations/zod) | +| [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot) | Integrate with [Valibot](https://valibot.dev/). | [Valibot](https://orpc.dev/docs/integrations/valibot) | +| [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype) | Integrate with [ArkType](https://arktype.io/). | [ArkType](https://orpc.dev/docs/integrations/arktype) | **Built-in features** -- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. -- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. -- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, Upstash, 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. +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** -- [@orpc/next](https://www.npmjs.com/package/@orpc/next): Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). -- [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. -- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). -- [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). -- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). -- [@orpc/experimental-msw](https://www.npmjs.com/package/@orpc/experimental-msw): Mock procedures with [Mock Service Worker](https://mswjs.io/). -- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). -- [@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 Workers](https://developers.cloudflare.com/workers/). -- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. +| Package | Purpose | Docs | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/next](https://www.npmjs.com/package/@orpc/next) | Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). | [Next.js](https://orpc.dev/docs/integrations/next) | +| [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk) | Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. | [AI SDK](https://orpc.dev/docs/integrations/ai-sdk) | +| [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query) | Integrate with [TanStack Query](https://tanstack.com/query/latest). | [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) | +| [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada) | Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). | [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada) | +| [@orpc/swr](https://www.npmjs.com/package/@orpc/swr) | Integrate with [SWR](https://swr.vercel.app/). | [SWR](https://orpc.dev/docs/integrations/swr) | +| [@orpc/experimental-msw](https://www.npmjs.com/package/@orpc/experimental-msw) | Mock procedures with [Mock Service Worker](https://mswjs.io/). | [MSW](https://orpc.dev/docs/integrations/msw) | +| [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | +| [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | +| [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | **Observability** -- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. -- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Integrate with [Pino](https://getpino.io/) for logging. -- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Integrate with [Evlog](https://evlog.dev/) for logging. +| Package | Purpose | Docs | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry) | Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. | [OpenTelemetry](https://orpc.dev/docs/integrations/opentelemetry) | +| [@orpc/pino](https://www.npmjs.com/package/@orpc/pino) | Integrate with [Pino](https://getpino.io/) for logging. | [Pino](https://orpc.dev/docs/integrations/pino) | +| [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog) | Integrate with [Evlog](https://evlog.dev/) for logging. | [Evlog](https://orpc.dev/docs/integrations/evlog) | ## Sponsors diff --git a/packages/msw/README.md b/packages/msw/README.md index b855f26a0..33bb8a192 100644 --- a/packages/msw/README.md +++ b/packages/msw/README.md @@ -46,29 +46,30 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, and Vercel adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@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). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** -| Package | Purpose | Docs | -| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [@orpc/next](https://www.npmjs.com/package/@orpc/next) | Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). | [Next.js](https://orpc.dev/docs/integrations/next) | -| [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk) | Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. | [AI SDK](https://orpc.dev/docs/integrations/ai-sdk) | -| [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query) | Integrate with [TanStack Query](https://tanstack.com/query/latest). | [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) | -| [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada) | Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). | [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada) | -| [@orpc/swr](https://www.npmjs.com/package/@orpc/swr) | Integrate with [SWR](https://swr.vercel.app/). | [SWR](https://orpc.dev/docs/integrations/swr) | -| [@orpc/experimental-msw](https://www.npmjs.com/package/@orpc/experimental-msw) | Mock procedures with [Mock Service Worker](https://mswjs.io/). | [MSW](https://orpc.dev/docs/integrations/msw) | -| [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | -| [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | -| [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). | [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | +| Package | Purpose | Docs | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/next](https://www.npmjs.com/package/@orpc/next) | Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). | [Next.js](https://orpc.dev/docs/integrations/next) | +| [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk) | Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. | [AI SDK](https://orpc.dev/docs/integrations/ai-sdk) | +| [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query) | Integrate with [TanStack Query](https://tanstack.com/query/latest). | [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) | +| [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada) | Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). | [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada) | +| [@orpc/swr](https://www.npmjs.com/package/@orpc/swr) | Integrate with [SWR](https://swr.vercel.app/). | [SWR](https://orpc.dev/docs/integrations/swr) | +| [@orpc/experimental-msw](https://www.npmjs.com/package/@orpc/experimental-msw) | Mock procedures with [Mock Service Worker](https://mswjs.io/). | [MSW](https://orpc.dev/docs/integrations/msw) | +| [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | +| [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | +| [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | **Observability** From 7958a2a207a372ba1051729b53c9b68c9a0821cf Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:22:39 +0700 Subject: [PATCH 24/35] fix(cache): wait for the stale-while-revalidate refresh without cache/waitUntil Without a runtime to hand the refresh to, the request now completes it before returning the stale output, so it cannot be dropped when the process or invocation ends. Failed refreshes still serve the stale entry. --- apps/content/docs/helpers/cache.mdx | 6 +++--- packages/cache/src/middleware.test.ts | 30 ++++++++++++++++++--------- packages/cache/src/middleware.ts | 7 ++++--- packages/cache/src/types.ts | 8 +++---- packages/cache/src/utils.test.ts | 19 ++++++++++------- packages/cache/src/utils.ts | 10 ++++----- 6 files changed, 46 insertions(+), 34 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index eac0526a9..0121b438c 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -244,9 +244,9 @@ A cached entry is shared by everyone using the same key. If output depends on th ### 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`. +When an entry is past `ttl` but within the `swr` window, the middleware serves the stale output and re-executes the procedure 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 `cache/waitUntil` through the context so background refreshes can finish: +Provide `cache/waitUntil` through the context, such as `ctx.waitUntil` on Cloudflare Workers, to run refreshes in the background after the response is sent: ```ts export default { @@ -263,7 +263,7 @@ export default { } ``` -The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are reported. Without it the refresh still runs, but its failures are ignored and the next stale hit retries. +The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are reported. Without it, the refresh completes before the stale output is returned, and its failures are ignored so the next stale hit retries. ## Revalidate Middleware diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index ad1c45d4a..e96e8f9b3 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -1,7 +1,7 @@ import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext, CacheEntry, CacheStore } from './types' import { call, os, type } from '@orpc/server' -import { nowInSeconds } from '@orpc/shared' +import { nowInSeconds, sleep } from '@orpc/shared' import { MemoryCacheStore } from './adapters/memory' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' @@ -230,15 +230,26 @@ describe('cache', () => { expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t'], ttl: 60, swr: 30 }) }) - it('refreshes in the background without waitUntil', async () => { + it('waits for the refresh before returning without waitUntil', async () => { const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + let finishRefresh!: (output: string) => void + const procedure = os.$context().use(cache({ key: 'k' })).handler( + () => new Promise((resolve) => { + finishRefresh = resolve + }), + ) - await expect( - call(procedure, undefined, { context: { 'cache/store': store } }), - ).resolves.toBe('stale') + const settled = vi.fn() + const result = call(procedure, undefined, { context: { 'cache/store': store } }).then(settled) + + await sleep(10) + expect(settled).not.toHaveBeenCalled() - await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined })) + finishRefresh('fresh') + + await result + expect(settled).toHaveBeenCalledWith('stale') + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) }) it('hands background refresh failures to waitUntil', async () => { @@ -257,7 +268,7 @@ describe('cache', () => { expect(store.set).not.toHaveBeenCalled() }) - it('ignores background refresh failures without waitUntil', async () => { + it('serves stale output when the awaited refresh fails without waitUntil', async () => { const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) const handlerFn = vi.fn(() => { throw new Error('handler down') @@ -268,8 +279,7 @@ describe('cache', () => { call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('stale') - // Nothing owns the refresh, so its rejection must not reach the process. - await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) + expect(handlerFn).toHaveBeenCalledTimes(1) expect(store.set).not.toHaveBeenCalled() }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index 691ac65cb..32c096256 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -34,7 +34,8 @@ export interface CacheMiddlewareOptions< /** * Extra stale-while-revalidate window in seconds after `ttl`. - * Stale entries are served immediately while the procedure re-executes in the background. + * Stale entries are served while the procedure re-executes to refresh the entry, + * in the background through `cache/waitUntil` or before returning without it. * * @default 0 */ @@ -99,8 +100,8 @@ export function cache< waitUntil(refresh) } else { - // Nothing owns it instead, and Node exits on an unhandled rejection. - refresh.catch(() => {}) + // Nothing else can own it, so the request waits for it; the stale output still stands if it fails. + await refresh.catch(() => {}) } } diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index a79311444..1297f5e39 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -88,10 +88,10 @@ export interface CacheContext { /** * Takes ownership of 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`). The promise rejects - * when the refresh fails, so this is also where such failures are reported; - * without it they are ignored. + * refreshes, like `ctx.waitUntil` on Cloudflare Workers. The promise rejects + * when the refresh fails, so this is also where such failures are reported. + * Without it, the refresh completes before the stale output is returned and + * its failures are ignored. */ 'cache/waitUntil'?: (promise: Promise) => void } diff --git a/packages/cache/src/utils.test.ts b/packages/cache/src/utils.test.ts index f1aa1c38a..cb7f8d8ff 100644 --- a/packages/cache/src/utils.test.ts +++ b/packages/cache/src/utils.test.ts @@ -1,22 +1,25 @@ +import { RPCJsonSerializer } from '@orpc/client' import { encodeCacheKey } from './utils' describe('encodeCacheKey', () => { + const serializer = new RPCJsonSerializer() + it('uses string keys verbatim', () => { - expect(encodeCacheKey('planet:1')).toBe('planet:1') + expect(encodeCacheKey('planet:1', serializer)).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([['planet', 'find'], { b: 2, a: 1 }], serializer)) + .toBe(encodeCacheKey([['planet', 'find'], { a: 1, b: 2 }], serializer)) - expect(encodeCacheKey({ date: new Date(1), big: 1n })) - .toBe(encodeCacheKey({ big: 1n, date: new Date(1) })) + expect(encodeCacheKey({ date: new Date(1), big: 1n }, serializer)) + .toBe(encodeCacheKey({ big: 1n, date: new Date(1) }, serializer)) - expect(encodeCacheKey({ big: 1n })).not.toBe(encodeCacheKey({ big: 2n })) + expect(encodeCacheKey({ big: 1n }, serializer)).not.toBe(encodeCacheKey({ big: 2n }, serializer)) }) it('ignores unsupported values like blobs', () => { - expect(encodeCacheKey({ file: new Blob(['a']), id: 1 })) - .toBe(encodeCacheKey({ file: new Blob(['b']), id: 1 })) + expect(encodeCacheKey({ file: new Blob(['a']), id: 1 }, serializer)) + .toBe(encodeCacheKey({ file: new Blob(['b']), id: 1 }, serializer)) }) }) diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index f97b09531..03eb21b2b 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -1,5 +1,5 @@ +import type { RPCJsonSerializer } from '@orpc/client' import type { Public } from '@orpc/shared' -import { RPCJsonSerializer } from '@orpc/client' import { deepSortKeys, stringifyJSON } from '@orpc/shared' /** @@ -11,14 +11,12 @@ import { deepSortKeys, stringifyJSON } from '@orpc/shared' * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export function encodeCacheKey(key: unknown, serializer?: Public): string { +export function encodeCacheKey(key: unknown, serializer: Public): string { if (typeof key === 'string') { return key } - // Built here rather than as a default parameter, which would construct one - // on every call, string keys included. - const { json, meta } = (serializer ?? new RPCJsonSerializer()).serialize(key) + const { json, meta } = serializer.serialize(key) - return `${stringifyJSON([deepSortKeys(json), meta?.map(entry => stringifyJSON(entry)).sort()])}` + return stringifyJSON([deepSortKeys(json), meta?.map(entry => stringifyJSON(entry)).sort()]) } From 479d744be3eb399c7541fd5ced9f3ad35867c40b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:48:02 +0700 Subject: [PATCH 25/35] fix(cache): hand the stale-while-revalidate refresh to cache/waitUntil only The refresh is started regardless and handed to cache/waitUntil when one is provided; without it nothing owns the refresh, so a failed one surfaces as an unhandled rejection rather than being awaited or swallowed. --- apps/content/docs/helpers/cache.mdx | 6 ++--- packages/cache/src/middleware.test.ts | 39 ++++++++++++--------------- packages/cache/src/middleware.ts | 14 ++-------- packages/cache/src/types.ts | 5 ++-- 4 files changed, 24 insertions(+), 40 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 0121b438c..ddb1a6cdd 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -244,9 +244,9 @@ A cached entry is shared by everyone using the same key. If output depends on th ### Stale While Revalidate -When an entry is past `ttl` but within the `swr` window, the middleware serves the stale output and re-executes the procedure to refresh the entry. Concurrent stale hits may each trigger a refresh; the cache never serves anything older than `ttl + swr`. +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`. -Provide `cache/waitUntil` through the context, such as `ctx.waitUntil` on Cloudflare Workers, to run refreshes in the background after the response is sent: +On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, provide `cache/waitUntil` through the context so background refreshes can finish: ```ts export default { @@ -263,7 +263,7 @@ export default { } ``` -The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are reported. Without it, the refresh completes before the stale output is returned, and its failures are ignored so the next stale hit retries. +The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are handled. Without it they surface as unhandled rejections, so on other runtimes provide one that reports them, for example `promise => promise.catch(console.error)`. ## Revalidate Middleware diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index e96e8f9b3..116094b22 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -1,7 +1,7 @@ import type { CacheHandlerPluginContext } from './handler-plugin' import type { CacheContext, CacheEntry, CacheStore } from './types' import { call, os, type } from '@orpc/server' -import { nowInSeconds, sleep } from '@orpc/shared' +import { nowInSeconds } from '@orpc/shared' import { MemoryCacheStore } from './adapters/memory' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' @@ -230,26 +230,15 @@ describe('cache', () => { expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t'], ttl: 60, swr: 30 }) }) - it('waits for the refresh before returning without waitUntil', async () => { + it('refreshes in the background without waitUntil', async () => { const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - let finishRefresh!: (output: string) => void - const procedure = os.$context().use(cache({ key: 'k' })).handler( - () => new Promise((resolve) => { - finishRefresh = resolve - }), - ) - - const settled = vi.fn() - const result = call(procedure, undefined, { context: { 'cache/store': store } }).then(settled) - - await sleep(10) - expect(settled).not.toHaveBeenCalled() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') - finishRefresh('fresh') + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe('stale') - await result - expect(settled).toHaveBeenCalledWith('stale') - expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) + await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined })) }) it('hands background refresh failures to waitUntil', async () => { @@ -268,18 +257,24 @@ describe('cache', () => { expect(store.set).not.toHaveBeenCalled() }) - it('serves stale output when the awaited refresh fails without waitUntil', async () => { + it('leaves refresh failures unhandled without waitUntil', async ({ onTestFinished }) => { + const unhandledRejectionHandler = vi.fn() + process.on('unhandledRejection', unhandledRejectionHandler) + + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionHandler) + }) + const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - const handlerFn = vi.fn(() => { + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => { throw new Error('handler down') }) - const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) await expect( call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('stale') - expect(handlerFn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => expect(unhandledRejectionHandler).toHaveBeenCalledWith(new Error('handler down'), expect.any(Promise))) expect(store.set).not.toHaveBeenCalled() }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index 32c096256..6a7b46864 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -34,8 +34,7 @@ export interface CacheMiddlewareOptions< /** * Extra stale-while-revalidate window in seconds after `ttl`. - * Stale entries are served while the procedure re-executes to refresh the entry, - * in the background through `cache/waitUntil` or before returning without it. + * Stale entries are served immediately while the procedure re-executes in the background. * * @default 0 */ @@ -93,16 +92,7 @@ export function cache< const refresh = Promise.resolve(middlewareOptions.next()) .then(result => store.set(key, result.output, { tags, ttl, swr })) - const waitUntil = middlewareOptions.context['cache/waitUntil'] - - if (waitUntil !== undefined) { - // The runtime owns the refresh from here, failures included. - waitUntil(refresh) - } - else { - // Nothing else can own it, so the request waits for it; the stale output still stands if it fails. - await refresh.catch(() => {}) - } + middlewareOptions.context['cache/waitUntil']?.(refresh) } pluginContext?.caches.push({ diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index 1297f5e39..a0a0a88c0 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -89,9 +89,8 @@ export interface CacheContext { /** * Takes ownership of background work such as stale-while-revalidate * refreshes, like `ctx.waitUntil` on Cloudflare Workers. The promise rejects - * when the refresh fails, so this is also where such failures are reported. - * Without it, the refresh completes before the stale output is returned and - * its failures are ignored. + * when the refresh fails, so this is also where such failures are handled; + * without it they surface as unhandled rejections. */ 'cache/waitUntil'?: (promise: Promise) => void } From f81e29c4eee91b973356ff9ee3c206b38c85b60b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:51:27 +0700 Subject: [PATCH 26/35] docs(cache): describe the headers option's current override behavior --- packages/cache/src/handler-plugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index db2e9cf8e..2b2c1d5a7 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -39,8 +39,10 @@ export interface CacheHandlerPluginOptions<_T extends Context> { * 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. + * caches in front, such as CDNs or Cloudflare Workers Caching. All are set + * over anything already on the response whenever the called procedure ran + * the cache middleware; standard HTTP caches only store GET and HEAD + * responses, so the last two mainly benefit OpenAPI routes. * * @default [] */ From 3aaafb6ba6b74fd2af61c14f45c1ae816e82760b Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:53:38 +0700 Subject: [PATCH 27/35] Update handler-plugin.ts --- packages/cache/src/handler-plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index 2b2c1d5a7..b4d60c852 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -41,8 +41,7 @@ export interface CacheHandlerPluginOptions<_T extends Context> { * and `cache-control` are their standard HTTP counterparts for response * caches in front, such as CDNs or Cloudflare Workers Caching. All are set * over anything already on the response whenever the called procedure ran - * the cache middleware; standard HTTP caches only store GET and HEAD - * responses, so the last two mainly benefit OpenAPI routes. + * the cache middleware; * * @default [] */ From 12694be42466d0c47c5c0dee9ca970dfea6b9681 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:54:53 +0700 Subject: [PATCH 28/35] improve --- packages/cache/src/handler-plugin.test.ts | 13 ------------- packages/cache/src/handler-plugin.ts | 4 +--- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts index 69ac087cb..29b7e067a 100644 --- a/packages/cache/src/handler-plugin.test.ts +++ b/packages/cache/src/handler-plugin.test.ts @@ -215,19 +215,6 @@ describe('cacheHandlerPlugin', () => { }) }) - it('sets its headers over ones the response already carries', async () => { - const { record, handle } = createTestingHandler( - ['orpc-cache-tag', 'cache-control'], - { 'orpc-cache-tag': 'preset', 'cache-control': 'private, no-store' }, - ) - record({ caches: [{ tags: ['planets'], ttl: 60 }] }) - - const response = await handle() - - expect(response.headers.get('orpc-cache-tag')).toBe('planets') - expect(response.headers.get('cache-control')).toBe('public, max-age=60') - }) - it('only reflects the tags of the procedure the client called in nested calls', async () => { const inner = os .$context() diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts index b4d60c852..9a253fa3b 100644 --- a/packages/cache/src/handler-plugin.ts +++ b/packages/cache/src/handler-plugin.ts @@ -39,9 +39,7 @@ export interface CacheHandlerPluginOptions<_T extends Context> { * 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. All are set - * over anything already on the response whenever the called procedure ran - * the cache middleware; + * caches in front, such as CDNs or Cloudflare Workers Caching. * * @default [] */ From 1c711593986ea2fd0609df3b402c4250e809cb86 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 4 Sep 2026 15:58:22 +0700 Subject: [PATCH 29/35] feat(cloudflare)!: remove experimental_KVCacheStore --- apps/content/docs/helpers/cache.mdx | 27 ---- packages/cloudflare/package.json | 1 - packages/cloudflare/src/index.ts | 1 - packages/cloudflare/src/kv-cache.test.ts | 130 --------------- packages/cloudflare/src/kv-cache.ts | 149 ------------------ packages/cloudflare/tests/__shared__/utils.ts | 40 ----- packages/cloudflare/wrangler.jsonc | 6 - 7 files changed, 354 deletions(-) delete mode 100644 packages/cloudflare/src/kv-cache.test.ts delete mode 100644 packages/cloudflare/src/kv-cache.ts delete mode 100644 packages/cloudflare/tests/__shared__/utils.ts diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index ddb1a6cdd..fcb3211c3 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -41,7 +41,6 @@ An entry stays fresh for `ttl` seconds and is retained for an extra `swr` window | `UpstashCacheStore` | [Upstash Redis](https://github.com/upstash/redis-js) | | `BunRedisCacheStore` | [Bun's Redis](https://bun.com/docs/runtime/redis) | | `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | -| `experimental_KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | | `experimental_WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | Every duration is in seconds, matching what the underlying caches accept. @@ -160,32 +159,6 @@ const store = new VercelCacheStore({ }) ``` -```ts cloudflare-kv -import { experimental_KVCacheStore as KVCacheStore } from '@orpc/cloudflare' - -export default { - async fetch(request, env) { - // KV is eventually consistent: writes and revalidations may take - // 60 seconds or more to be visible in other locations. - const store = new KVCacheStore(env.CACHE_KV, { - /** - * The prefix to use for KV keys. - * - * @default undefined - */ - prefix: undefined, - - /** - * Serializer for cached outputs. - * - * @default RPCSerializer - */ - serializer: undefined, - }) - }, -} -``` - ```ts cloudflare-workers-caching import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare' diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 68d21d3dc..7ce8af19d 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -22,7 +22,6 @@ "ratelimit", "pubsub", "cache", - "kv", "typescript" ], "sideEffects": false, diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 128ce5df9..66eac9499 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,4 +1,3 @@ -export * from './kv-cache' export * from './publisher' export * from './publisher-object' export * from './ratelimit' diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts deleted file mode 100644 index 10f060dde..000000000 --- a/packages/cloudflare/src/kv-cache.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { experimental_KVCacheStoreOptions } from './kv-cache' -import { RPCSerializer } from '@orpc/client' -import { nowInSeconds } from '@orpc/shared' -import { env } from 'cloudflare:workers' -import { describe, expect, it, vi } from 'vitest' -import { holdResult } from '../tests/__shared__/utils' -import { experimental_KVCacheStore } from './kv-cache' - -describe('experimental_KVCacheStore', () => { - function createTestingStore(options: Partial = {}) { - const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - return { store: new experimental_KVCacheStore(env.CACHE_KV, { prefix, ...options }), prefix } - } - - // The cross-package tsconfig rootDir keeps the shared store contract out of - // reach here, so the shared behavior is asserted again against real KV. - it('round-trips outputs with their tags and expiresAt, including undefined', async () => { - const { store } = createTestingStore() - - await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120 }) - - const entry = await store.get('k') - expect(entry!.output).toEqual({ nested: [1, 2] }) - expect(entry!.tags).toEqual(['t']) - expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) - - await store.set('u', undefined) - await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) - await expect(store.get('unknown')).resolves.toBeUndefined() - }) - - 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, - } - - await store.set('k', output) - await expect(store.get('k')).resolves.toMatchObject({ output }) - }) - - it('invalidates entries by any of their tags, and keeps ones set afterwards', async () => { - const { store } = createTestingStore() - - await store.set('multi', 'v', { tags: ['a', 'b'] }) - await store.set('other', 'v', { tags: ['c'] }) - - await store.revalidate({ tags: ['a', 'b'] }) - - await expect(store.get('multi')).resolves.toBeUndefined() - await expect(store.get('other')).resolves.toBeDefined() - - await store.set('multi', 'new', { tags: ['a'] }) - await expect(store.get('multi')).resolves.toMatchObject({ output: 'new' }) - }) - - it('supports a custom serializer', async () => { - 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(serializeSpy).toHaveBeenCalled() - expect(deserializeSpy).toHaveBeenCalled() - }) - - 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: 'v' }, - tags: [], - tagTokens: {}, - expiresAt, - evictAt, - }) - - await env.CACHE_KV.put(`${prefix}e:stale`, envelope(nowInSeconds() - 1, nowInSeconds() + 60)) - await env.CACHE_KV.put(`${prefix}e:evicted`, envelope(nowInSeconds() - 2, nowInSeconds() - 1)) - - const stale = await store.get('stale') - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) - - await expect(store.get('evicted')).resolves.toBeUndefined() - await expect(env.CACHE_KV.get(`${prefix}e:evicted`)).resolves.toBeNull() - }) - - it('defaults to no prefix', async () => { - const store = new experimental_KVCacheStore(env.CACHE_KV) - const key = crypto.randomUUID() - - await store.set(key, 'v') - - await expect(env.CACHE_KV.get(`e:${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() - - await store.set('k', 'v', { tags: ['t'] }) - await store.revalidate({ tags: ['t'] }) - - await expect(env.CACHE_KV.get(`${prefix}e:k`)).resolves.toBeTypeOf('string') - await expect(env.CACHE_KV.get(`${prefix}t:t`)).resolves.toBeTypeOf('string') - }) - - it('drops an entry whose tag tokens were read before a racing revalidation', async () => { - const { client: kv, read, release } = holdResult(env.CACHE_KV, 'get') - const prefix = `orpc-kv-cache-store-${crypto.randomUUID()}:` - const store = new experimental_KVCacheStore(kv, { prefix }) - - const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release - await read // tokens are read by now - await store.revalidate({ tags: ['t'] }) - release() - await set - - await expect(store.get('k')).resolves.toBeUndefined() - await expect(env.CACHE_KV.get(`${prefix}e:k`)).resolves.toBeNull() - }) -}) diff --git a/packages/cloudflare/src/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts deleted file mode 100644 index cac253ad2..000000000 --- a/packages/cloudflare/src/kv-cache.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' -import type { Public } from '@orpc/shared' -import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { encodeCacheKey } from '@orpc/experimental-cache' -import { nowInSeconds, stringifyJSON } from '@orpc/shared' - -interface KVCacheStoreEnvelope { - /** - * The cached output, encoded with the store's serializer. - */ - output: unknown - 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 experimental_KVCacheStoreOptions { - /** - * The prefix to use for KV keys. - * - * @default undefined - */ - prefix?: string - - /** - * Serializer for cached outputs. - * - * @default RPCSerializer - */ - serializer?: undefined | Public -} - -/** - * 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 experimental_KVCacheStore implements CacheStore { - private readonly prefix: string - private readonly serializer: Public - - /** - * Key encoding has no serializer option, so one is built here rather than - * per call by {@link encodeCacheKey}. - */ - private readonly keySerializer = new RPCJsonSerializer() - - constructor( - private readonly kv: KVNamespace, - options: experimental_KVCacheStoreOptions = {}, - ) { - this.prefix = options.prefix ?? '' - this.serializer = options.serializer ?? new RPCSerializer() - } - - async get(key: unknown): Promise { - const entryKey = this.entryKey(key) - const envelope = await this.kv.get(entryKey, 'json') - - if (envelope === null) { - return undefined - } - - if (envelope.evictAt !== undefined && nowInSeconds() >= envelope.evictAt) { - await this.kv.delete(entryKey) - 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(entryKey) - return undefined - } - } - - return { - output: this.serializer.deserialize(envelope.output as any), - tags: envelope.tags, - expiresAt: envelope.expiresAt, - } - } - - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const serialized = this.serializer.serialize(output) - - const tags = options?.tags - - let tagTokens: Record | undefined - if (tags?.length) { - const tokens = await Promise.all(tags.map(tag => this.kv.get(this.tagKey(tag)))) - tagTokens = {} - 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 ? nowInSeconds() + options.ttl : undefined - const evictAt = retention !== undefined ? nowInSeconds() + retention : undefined - - const envelope: KVCacheStoreEnvelope = { - 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, retention) } : {}, - ) - } - - async revalidate({ tags }: CacheRevalidateOptions): Promise { - await Promise.all(tags.map(tag => this.kv.put(this.tagKey(tag), crypto.randomUUID()))) - } - - private entryKey(key: unknown): string { - return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` - } - - private tagKey(tag: string): string { - return `${this.prefix}t:${tag}` - } -} diff --git a/packages/cloudflare/tests/__shared__/utils.ts b/packages/cloudflare/tests/__shared__/utils.ts deleted file mode 100644 index c82d9f0d3..000000000 --- a/packages/cloudflare/tests/__shared__/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Await `read` to know the first held - * call has completed, then run a racing operation before `release` to land it - * between that read and whatever follows it. - */ -export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - - let settle!: () => void - const read = new Promise((resolve) => { - settle = resolve - }) - - const proxy = new Proxy(client, { - get(target, prop) { - const value = Reflect.get(target, prop) - - if (typeof value !== 'function') { - return value - } - - if (prop !== method) { - return value.bind(target) - } - - return async (...args: unknown[]) => { - const result = await value.apply(target, args) - settle() - await gate - return result - } - }, - }) - - return { client: proxy, read, release } -} diff --git a/packages/cloudflare/wrangler.jsonc b/packages/cloudflare/wrangler.jsonc index f1a82d2cb..a3196f350 100644 --- a/packages/cloudflare/wrangler.jsonc +++ b/packages/cloudflare/wrangler.jsonc @@ -2,12 +2,6 @@ "$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", From 082d46f7a23c7fcf6f141a701a20d0d3484295cf Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 5 Sep 2026 16:22:30 +0700 Subject: [PATCH 30/35] feat(cache): add per-key locks so concurrent misses fill an entry once CacheStore gains an optional lock(key, fn) that runs fn for one caller at a time per key, reporting whether the caller waited. The middleware fills a miss and refreshes a stale entry under it, re-reading only after waiting so hits never read twice. Redis, Upstash, and Bun hold the lock in Redis with SET NX and a token-checked release; memory and Vercel hold it in process. --- apps/content/docs/helpers/cache.mdx | 29 ++++- packages/bun/src/redis-cache.test.ts | 65 +++++++++- packages/bun/src/redis-cache.ts | 48 ++++++- ...cache-redis-adapters-compatibility.test.ts | 31 +++++ packages/cache/src/adapters/memory.ts | 8 +- packages/cache/src/adapters/redis.test.ts | 77 ++++++++++- packages/cache/src/adapters/redis.ts | 50 +++++++- packages/cache/src/adapters/upstash.test.ts | 30 +++++ packages/cache/src/adapters/upstash.ts | 46 ++++++- packages/cache/src/adapters/vercel.test.ts | 30 +++++ packages/cache/src/adapters/vercel.ts | 10 +- packages/cache/src/middleware.test.ts | 121 +++++++++++++++++- packages/cache/src/middleware.ts | 83 +++++++++--- packages/cache/src/types.ts | 8 ++ packages/cache/src/utils.ts | 29 +++++ .../cache/tests/__shared__/store-contract.ts | 58 ++++++++- .../redis-adapters-compatibility.test.ts | 31 +++++ packages/cloudflare/src/workers-cache.test.ts | 14 +- packages/cloudflare/src/workers-cache.ts | 30 ++--- 19 files changed, 732 insertions(+), 66 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index fcb3211c3..28c6a2afd 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -13,7 +13,7 @@ npm install @orpc/experimental-cache@beta ## Basic Usage -The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. You can create your own custom store or use one of the provided adapters. A router shares a single store, provided through the request context under the `cache/store` key, as defined by the `CacheContext` interface. +The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. Stores may also implement `lock`, which runs a callback for one caller at a time per key so concurrent misses fill an entry once. 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 under the `cache/store` key, as defined by the `CacheContext` interface. ```ts twoslash import { MemoryCacheStore } from '@orpc/experimental-cache/memory' @@ -47,6 +47,8 @@ Every duration is in seconds, matching what the underlying caches accept. 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. +Every adapter except `experimental_WorkersCacheStore` implements `lock`. The Redis, Upstash, and Bun stores hold it in Redis with `SET NX`, so it spans processes and is released when the holder finishes, or after `lockTtl` if it never does. The memory and Vercel stores hold it within the process; `MemoryLock` is exported for stores of your own. + ```ts memory @@ -86,6 +88,13 @@ const store = new RedisCacheStore(client, { * @default RPCSerializer */ serializer: undefined, + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its waiters. + * + * @default 10 + */ + lockTtl: 10, }) ``` @@ -111,6 +120,13 @@ const store = new UpstashCacheStore(redis, { * @default RPCSerializer */ serializer: undefined, + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its waiters. + * + * @default 10 + */ + lockTtl: 10, }) ``` @@ -134,6 +150,13 @@ const store = new BunRedisCacheStore(redis, { * @default RPCSerializer */ serializer: undefined, + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its waiters. + * + * @default 10 + */ + lockTtl: 10, }) ``` @@ -177,7 +200,7 @@ const store = new WorkersCacheStore() ## 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 `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. When the store implements `lock`, concurrent misses for one key run the handler once and the rest are served from the entry it stores. 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, it is used as given, so procedures sharing a key also share an entry. @@ -217,7 +240,7 @@ A cached entry is shared by everyone using the same key. If output depends on th ### 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`. +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 refresh once when the store implements `lock`, and once each otherwise; 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 `cache/waitUntil` through the context so background refreshes can finish: diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts index 66e361883..90869675d 100644 --- a/packages/bun/src/redis-cache.test.ts +++ b/packages/bun/src/redis-cache.test.ts @@ -2,7 +2,7 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { RedisClient } from 'bun' import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test' -import { holdResult } from '../tests/__shared__/utils' +import { holdResult, waitFor } from '../tests/__shared__/utils' import { BunRedisCacheStore } from './redis-cache' const REDIS_URL = Bun.env.REDIS_URL @@ -168,4 +168,67 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) }) + + it('runs lock callbacks one key at a time, handing on after failures', async () => { + const { store } = createTestingStore() + const order: string[] = [] + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + + const first = store.lock('k', async (waited) => { + order.push(`first:${waited}`) + await held + return 'first' + }) + await waitFor(() => expect(order).toEqual(['first:false']), { timeout: 5000 }) + + const second = store.lock('k', async (waited) => { + order.push(`second:${waited}`) + return 'second' + }) + await expect(store.lock('other', async waited => waited)).resolves.toBe(false) + expect(order).toEqual(['first:false']) + + release() + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + expect(order).toEqual(['first:false', 'second:true']) + + await expect(store.lock('k', async () => { + throw new Error('boom') + })).rejects.toThrow('boom') + await expect(store.lock('k', async waited => waited)).resolves.toBe(false) + }) + + it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { + const { store, prefix } = createTestingStore({ lockTtl: 1 }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let takenOver!: () => void + const takeover = new Promise((resolve) => { + takenOver = resolve + }) + + // Holds past its ttl, until the waiter has taken the lock over. + const holder = store.lock('k', () => takeover) + await waitFor(async () => expect(await redis.exists(`${prefix}l:k`)).toBe(true), { timeout: 5000 }) + + const waiter = store.lock('k', async (waited) => { + takenOver() + await held + return waited + }) + + await holder + // The holder's release must leave the waiter's lock alone. + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(true) + + release() + await expect(waiter).resolves.toBe(true) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(false) + }, { timeout: 20_000 }) }) diff --git a/packages/bun/src/redis-cache.ts b/packages/bun/src/redis-cache.ts index 73b7407ae..754e1d689 100644 --- a/packages/bun/src/redis-cache.ts +++ b/packages/bun/src/redis-cache.ts @@ -3,7 +3,18 @@ import type { Public } from '@orpc/shared' import type { RedisClient } from 'bun' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { encodeCacheKey } from '@orpc/experimental-cache' -import { nowInSeconds, stringifyJSON } from '@orpc/shared' +import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' + +/** + * Deletes the lock only while it still holds the caller's token, leaving one + * that expired and was taken over alone. + */ +const RELEASE_LOCK_SCRIPT = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +` interface BunRedisCacheStoreEnvelope { /** @@ -32,6 +43,14 @@ export interface BunRedisCacheStoreOptions { * @default RPCSerializer */ serializer?: undefined | Public + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its + * waiters. A fill outlasting it lets the next waiter fill as well. + * + * @default 10 + */ + lockTtl?: number } /** @@ -40,13 +59,15 @@ export interface BunRedisCacheStoreOptions { * so both can serve the same database. Entries are retained for `ttl + swr` * via `EX` 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. + * next `get` of their key. Locks are held in Redis with `SET NX`, so they + * span processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class BunRedisCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: Public + private readonly lockTtl: number /** * Key encoding has no serializer option, so one is built here rather than @@ -60,6 +81,7 @@ export class BunRedisCacheStore implements CacheStore { ) { this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() + this.lockTtl = options.lockTtl ?? 10 } async get(key: unknown): Promise { @@ -132,6 +154,24 @@ export class BunRedisCacheStore implements CacheStore { await Promise.all(tags.map(tag => this.redis.incr(this.tagKey(tag)))) } + async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { + const lockKey = this.lockKey(key) + const token = crypto.randomUUID() + let waited = false + + while (await this.redis.set(lockKey, token, 'PX', String(this.lockTtl * 1000), 'NX') === null) { + waited = true + await sleep(50) // until the holder releases, or its ttl passes + } + + try { + return await fn(waited) + } + finally { + await this.redis.send('EVAL', [RELEASE_LOCK_SCRIPT, '1', lockKey, token]) + } + } + private entryKey(key: unknown): string { return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` } @@ -139,4 +179,8 @@ export class BunRedisCacheStore implements CacheStore { private tagKey(tag: string): string { return `${this.prefix}t:${tag}` } + + private lockKey(key: unknown): string { + return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + } } diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts index c93e449a4..c6f4abd91 100644 --- a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -93,6 +93,37 @@ describe.concurrent('cache redis adapters compatibility', async () => { expect(stale!.output).toBe('v') expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) }, { timeout: 20_000 }) + + it(`shares locks: ${source.name} β†’ ${target.name}`, async () => { + const key = `lock:${crypto.randomUUID()}` + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let acquired!: () => void + const holding = new Promise((resolve) => { + acquired = resolve + }) + + const holder = source.store.lock!(key, async () => { + acquired() + await held + }) + await holding + + let settled = false + const waiter = target.store.lock!(key, async waited => waited).then((waited) => { + settled = true + return waited + }) + + await sleep(300) + expect(settled).toBe(false) + + release() + await holder + await expect(waiter).resolves.toBe(true) + }, { timeout: 20_000 }) } } }) diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index 691db1d8a..f0bc7ec41 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -2,7 +2,7 @@ import type { Public } from '@orpc/shared' import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' import { RPCJsonSerializer } from '@orpc/client' import { nowInSeconds } from '@orpc/shared' -import { encodeCacheKey } from '../utils' +import { encodeCacheKey, MemoryLock } from '../utils' export interface MemoryCacheStoreOptions { /** @@ -29,6 +29,7 @@ interface MemoryCacheStoreEntry { * 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. + * Locks are held within the process. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -36,6 +37,7 @@ export class MemoryCacheStore implements CacheStore { private readonly entries = new Map() private readonly tagVersions = new Map() private readonly serializer: Public + private readonly memoryLock = new MemoryLock() constructor(options: MemoryCacheStoreOptions = {}) { this.serializer = options.serializer ?? new RPCJsonSerializer() @@ -89,4 +91,8 @@ export class MemoryCacheStore implements CacheStore { this.tagVersions.set(tag, (this.tagVersions.get(tag) ?? 0) + 1) } } + + async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { + return this.memoryLock.run(encodeCacheKey(key, this.serializer), fn) + } } diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 4fdf4678d..97508d4ad 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -99,6 +99,36 @@ describe.concurrent('redis cache store integration', { await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) }) + + it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { + const { store, prefix } = createTestingStore({ lockTtl: 1 }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let takenOver!: () => void + const takeover = new Promise((resolve) => { + takenOver = resolve + }) + + // Holds past its ttl, until the waiter has taken the lock over. + const holder = store.lock('k', () => takeover) + await vi.waitFor(() => expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1), { timeout: 5000 }) + + const waiter = store.lock('k', async (waited) => { + takenOver() + await held + return waited + }) + + await holder + // The holder's release must leave the waiter's lock alone. + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) + + release() + await expect(waiter).resolves.toBe(true) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) + }) }) describe('redis cache store with a mocked client', () => { @@ -130,10 +160,21 @@ describe('redis cache store with a mocked client', () => { redis.isOpen = true }), get: vi.fn(async (key: string): Promise => data.get(key) ?? null), - set: vi.fn(async (key: string, value: string, _options?: unknown) => { + set: vi.fn(async (key: string, value: string, options?: { condition?: 'NX' }) => { + if (options?.condition === 'NX' && data.has(key)) { + return null + } data.set(key, value) return 'OK' }), + eval: vi.fn(async (_script: string, options: { keys: string[], arguments: string[] }) => { + // The release script: delete only while the key still holds the token. + if (data.get(options.keys[0]!) !== options.arguments[0]) { + return 0 + } + data.delete(options.keys[0]!) + return 1 + }), del: vi.fn(async (key: string) => (data.delete(key) ? 1 : 0)), incr: vi.fn(async (key: string) => incr(key)), mGet: vi.fn(async (keys: string[]): Promise<(string | null)[]> => keys.map(key => data.get(key) ?? null)), @@ -286,4 +327,38 @@ describe('redis cache store with a mocked client', () => { expect(redis.get.mock.calls[0]![0]).toBe(redis.get.mock.calls[1]![0]) expect(redis.get.mock.calls[0]![0]).toMatch(/^p:e:\[/) }) + + it('holds locks under the prefixed key for lockTtl and releases them with their token', async () => { + const { redis } = createMockedRedis() + const store = new RedisCacheStore(redis as any, { prefix: 'p:', lockTtl: 2 }) + + await store.lock('k', async (waited) => { + expect(waited).toBe(false) + expect(redis.set).toHaveBeenCalledWith('p:l:k', expect.any(String), { condition: 'NX', expiration: { type: 'PX', value: 2000 } }) + await expect(redis.get('p:l:k')).resolves.toEqual(expect.any(String)) + }) + + const token = redis.set.mock.calls[0]![1] + expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('DEL'), { keys: ['p:l:k'], arguments: [token] }) + await expect(redis.get('p:l:k')).resolves.toBeNull() + }) + + it('polls a held lock every 50ms with a 10 second ttl by default', async () => { + const { store, redis } = createMockedStore() + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + + const first = store.lock('k', () => held) + await vi.waitFor(() => expect(redis.set).toHaveBeenCalledWith('p:l:k', expect.any(String), { condition: 'NX', expiration: { type: 'PX', value: 10_000 } })) + const second = store.lock('k', async waited => waited) + + await sleep(120) + expect(redis.set.mock.calls.filter(([key]) => key === 'p:l:k').length).toBeGreaterThan(2) + + release() + await first + await expect(second).resolves.toBe(true) + }) }) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index d8caab691..5f87a030f 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -2,9 +2,20 @@ import type { Public } from '@orpc/shared' import type { RedisClientType } from 'redis' import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { nowInSeconds, stringifyJSON } from '@orpc/shared' +import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' import { encodeCacheKey } from '../utils' +/** + * Deletes the lock only while it still holds the caller's token, leaving one + * that expired and was taken over alone. + */ +const RELEASE_LOCK_SCRIPT = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +` + interface RedisCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. @@ -32,19 +43,29 @@ export interface RedisCacheStoreOptions { * @default RPCSerializer */ serializer?: undefined | Public + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its + * waiters. A fill outlasting it lets the next waiter fill as well. + * + * @default 10 + */ + lockTtl?: number } /** * Cache store adapter for Redis with tag-based invalidation. Entries are * retained for `ttl + swr` via `EX` 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. Locks are held in + * Redis with `SET NX`, so they span processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class RedisCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: Public + private readonly lockTtl: number /** * Key encoding has no serializer option, so one is built here rather than @@ -61,6 +82,7 @@ export class RedisCacheStore implements CacheStore { ) { this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() + this.lockTtl = options.lockTtl ?? 10 } async get(key: unknown): Promise { @@ -143,6 +165,26 @@ export class RedisCacheStore implements CacheStore { await multi.exec() } + async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { + await this.ensureConnection() + + const lockKey = this.lockKey(key) + const token = crypto.randomUUID() + let waited = false + + while (await this.redis.set(lockKey, token, { condition: 'NX', expiration: { type: 'PX', value: this.lockTtl * 1000 } }) === null) { + waited = true + await sleep(50) // until the holder releases, or its ttl passes + } + + try { + return await fn(waited) + } + finally { + await this.redis.eval(RELEASE_LOCK_SCRIPT, { keys: [lockKey], arguments: [token] }) + } + } + private entryKey(key: unknown): string { return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` } @@ -151,6 +193,10 @@ export class RedisCacheStore implements CacheStore { return `${this.prefix}t:${tag}` } + private lockKey(key: unknown): string { + return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + } + private async ensureConnection(): Promise { if (!this.redis.isOpen) { await this.redis.connect() diff --git a/packages/cache/src/adapters/upstash.test.ts b/packages/cache/src/adapters/upstash.test.ts index 5c974bec9..d7ef7f87f 100644 --- a/packages/cache/src/adapters/upstash.test.ts +++ b/packages/cache/src/adapters/upstash.test.ts @@ -118,4 +118,34 @@ describe.concurrent('upstash cache store integration', { await expect(store.get('k')).resolves.toBeUndefined() await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(0) }) + + it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { + const { store, prefix } = createTestingStore({ lockTtl: 1 }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let takenOver!: () => void + const takeover = new Promise((resolve) => { + takenOver = resolve + }) + + // Holds past its ttl, until the waiter has taken the lock over. + const holder = store.lock('k', () => takeover) + await vi.waitFor(() => expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1), { timeout: 5000 }) + + const waiter = store.lock('k', async (waited) => { + takenOver() + await held + return waited + }) + + await holder + // The holder's release must leave the waiter's lock alone. + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) + + release() + await expect(waiter).resolves.toBe(true) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) + }) }) diff --git a/packages/cache/src/adapters/upstash.ts b/packages/cache/src/adapters/upstash.ts index 1d7354330..00ea7417a 100644 --- a/packages/cache/src/adapters/upstash.ts +++ b/packages/cache/src/adapters/upstash.ts @@ -2,9 +2,20 @@ import type { Public } from '@orpc/shared' import type { Redis } from '@upstash/redis' import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { nowInSeconds, stringifyJSON } from '@orpc/shared' +import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' import { encodeCacheKey } from '../utils' +/** + * Deletes the lock only while it still holds the caller's token, leaving one + * that expired and was taken over alone. + */ +const RELEASE_LOCK_SCRIPT = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0 +` + interface UpstashCacheStoreEnvelope { /** * The cached output, encoded with the store's serializer. @@ -32,6 +43,14 @@ export interface UpstashCacheStoreOptions { * @default RPCSerializer */ serializer?: undefined | Public + + /** + * How long a lock may be held, in seconds, so a crashed holder frees its + * waiters. A fill outlasting it lets the next waiter fill as well. + * + * @default 10 + */ + lockTtl?: number } /** @@ -40,12 +59,14 @@ export interface UpstashCacheStoreOptions { * same database. Entries are retained for `ttl + swr` via `EX` 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. + * Locks are held in Redis with `SET NX`, so they span processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class UpstashCacheStore implements CacheStore { private readonly prefix: string private readonly serializer: Public + private readonly lockTtl: number /** * Key encoding has no serializer option, so one is built here rather than @@ -59,6 +80,7 @@ export class UpstashCacheStore implements CacheStore { ) { this.prefix = options.prefix ?? '' this.serializer = options.serializer ?? new RPCSerializer() + this.lockTtl = options.lockTtl ?? 10 } async get(key: unknown): Promise { @@ -136,6 +158,24 @@ export class UpstashCacheStore implements CacheStore { await multi.exec() } + async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { + const lockKey = this.lockKey(key) + const token = crypto.randomUUID() + let waited = false + + while (await this.redis.set(lockKey, token, { nx: true, px: this.lockTtl * 1000 }) === null) { + waited = true + await sleep(50) // until the holder releases, or its ttl passes + } + + try { + return await fn(waited) + } + finally { + await this.redis.eval(RELEASE_LOCK_SCRIPT, [lockKey], [token]) + } + } + private entryKey(key: unknown): string { return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` } @@ -143,4 +183,8 @@ export class UpstashCacheStore implements CacheStore { private tagKey(tag: string): string { return `${this.prefix}t:${tag}` } + + private lockKey(key: unknown): string { + return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + } } diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index 886d52a1e..775331dfb 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -24,6 +24,36 @@ describe('vercelCacheStore', () => { }) }) + describe('locking', () => { + it('locks per key within the process, handing on after failures', async () => { + const store = new VercelCacheStore() + const order: string[] = [] + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + + const first = store.lock('k', async (waited) => { + order.push(`first:${waited}`) + await held + }) + const second = store.lock('k', async (waited) => { + order.push(`second:${waited}`) + }) + await expect(store.lock('other', async waited => waited)).resolves.toBe(false) + expect(order).toEqual(['first:false']) + + release() + await Promise.all([first, second]) + expect(order).toEqual(['first:false', 'second:true']) + + await expect(store.lock('k', async () => { + throw new Error('boom') + })).rejects.toThrow('boom') + await expect(store.lock('k', async waited => waited)).resolves.toBe(false) + }) + }) + describe('against a mocked runtime cache', () => { function createMockedCache() { const values = new Map() diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 4fa8680cf..20f1eec3c 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -4,7 +4,7 @@ import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { nowInSeconds } from '@orpc/shared' import { getCache } from '@vercel/functions' -import { encodeCacheKey } from '../utils' +import { encodeCacheKey, MemoryLock } from '../utils' interface VercelCacheStoreEnvelope { /** @@ -36,7 +36,8 @@ export interface VercelCacheStoreOptions { * Cache store adapter for the Vercel Runtime Cache. Tags are expired * natively via `expireTag`, and entries are retained for `ttl + swr`. * Outside Vercel, the default `getCache()` falls back to an in-memory - * cache. + * cache. Locks are held within the process, since the Runtime Cache has no + * atomic primitive. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -49,6 +50,7 @@ export class VercelCacheStore implements CacheStore { * per call by {@link encodeCacheKey}. */ private readonly keySerializer = new RPCJsonSerializer() + private readonly memoryLock = new MemoryLock() constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() @@ -99,4 +101,8 @@ export class VercelCacheStore implements CacheStore { async revalidate({ tags }: CacheRevalidateOptions): Promise { await this.cache.expireTag([...tags]) } + + async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { + return this.memoryLock.run(encodeCacheKey(key, this.keySerializer), fn) + } } diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index 116094b22..dc3efc887 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -6,14 +6,20 @@ import { MemoryCacheStore } from './adapters/memory' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' -function createStore(entry?: CacheEntry) { +function createStore(entry?: CacheEntry, lock?: CacheStore['lock']) { return { get: vi.fn().mockResolvedValue(entry), set: vi.fn().mockResolvedValue(undefined), revalidate: vi.fn().mockResolvedValue(undefined), + lock, } } +/** + * A lock that always reports waiting, to reach the re-read paths directly. + */ +const alwaysWaitedLock: CacheStore['lock'] = (_key, fn) => fn(true) + describe('cache', () => { it('runs the handler and stores the output on miss', async () => { const store = createStore() @@ -280,35 +286,136 @@ describe('cache', () => { }) describe('concurrency', () => { - it('runs the handler once per concurrent miss, then serves the stored output', async () => { + it('fills once for concurrent misses when the store locks, serving the rest from the entry', async () => { const store = new MemoryCacheStore() + let finish!: (output: string) => void + const handlerFn = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const pluginContext = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'], ttl: 60 })) + .handler(handlerFn) + const run = () => call(procedure, undefined, { + context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, + path: ['__path__'], + }) + + const results = Promise.all([run(), run(), run()]) + await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) + finish('fresh') + + await expect(results).resolves.toEqual(['fresh', 'fresh', 'fresh']) + expect(handlerFn).toHaveBeenCalledTimes(1) + // One miss, then two hits served from the entry it filled. + expect(pluginContext.caches).toHaveLength(3) + expect(pluginContext.caches).toContainEqual({ procedure, path: ['__path__'], tags: ['t'], ttl: 60, swr: undefined }) + }) + + it('fills once per caller when the store has no lock', async () => { + const store = createStore() const handlerFn = vi.fn(() => 'fresh') const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) await expect(Promise.all([run(), run()])).resolves.toEqual(['fresh', 'fresh']) - expect(handlerFn).toHaveBeenCalledTimes(2) // misses are not coalesced + expect(handlerFn).toHaveBeenCalledTimes(2) + }) - await expect(run()).resolves.toBe('fresh') + it('lets a waiter fill when the holder failed to', async () => { + const store = new MemoryCacheStore() + let fail!: (error: Error) => void + const handlerFn = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) + + const first = run() + await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) + const second = run() + fail(new Error('handler down')) + + await expect(first).rejects.toThrow('handler down') + await expect(second).resolves.toBe('fresh') expect(handlerFn).toHaveBeenCalledTimes(2) }) - it('serves every concurrent stale hit immediately, refreshing once per hit', async () => { + it('fills when a waiter re-reads a still missing entry', async () => { + const store = createStore(undefined, alwaysWaitedLock) + const handlerFn = vi.fn(() => 'fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe('fresh') + + expect(store.get).toHaveBeenCalledTimes(2) + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) + }) + + it('refreshes once for concurrent stale hits when the store locks', async () => { const store = new MemoryCacheStore() await store.set('k', 'stale', { ttl: 0, swr: 60 }) - const handlerFn = vi.fn(() => 'fresh') + let finish!: (output: string) => void + const handlerFn = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) const waitUntil = vi.fn() const procedure = os.$context().use(cache({ key: 'k', ttl: 60 })).handler(handlerFn) const run = () => call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) await expect(Promise.all([run(), run()])).resolves.toEqual(['stale', 'stale']) - expect(handlerFn).toHaveBeenCalledTimes(2) // refreshes are not coalesced either + expect(waitUntil).toHaveBeenCalledTimes(2) + finish('fresh') await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(handlerFn).toHaveBeenCalledTimes(1) await expect(run()).resolves.toBe('fresh') + }) + + it('lets a waiter refresh when the holder failed to', async () => { + const store = new MemoryCacheStore() + await store.set('k', 'stale', { ttl: 0, swr: 60 }) + + let fail!: (error: Error) => void + const handlerFn = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockReturnValue('fresh') + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k', ttl: 60 })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) + + await expect(Promise.all([run(), run()])).resolves.toEqual(['stale', 'stale']) + fail(new Error('handler down')) + + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') + await waitUntil.mock.calls[1]![0] expect(handlerFn).toHaveBeenCalledTimes(2) + + await expect(run()).resolves.toBe('fresh') + }) + + it('refreshes when a waiter re-reads an evicted entry', async () => { + const store = createStore(undefined, alwaysWaitedLock) + store.get.mockResolvedValueOnce({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + + await expect( + call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }), + ).resolves.toBe('stale') + + await waitUntil.mock.calls[0]![0] + expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) }) }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index 6a7b46864..f6b21ff01 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -1,7 +1,7 @@ import type { Middleware, MiddlewareOptions } from '@orpc/server' import type { Promisable, Value } from '@orpc/shared' import type { CacheHandlerPluginContext } from './handler-plugin' -import type { CacheContext } from './types' +import type { CacheContext, CacheEntry, CacheStore } from './types' import { nowInSeconds, value } from '@orpc/shared' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' @@ -79,19 +79,30 @@ export function cache< const store = middlewareOptions.context['cache/store'] const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] - const entry = await store.get(key) + const fill = async () => { + const result = await middlewareOptions.next() + await store.set(key, result.output, { tags, ttl, swr }) + return result + } - if (entry) { - /** - * The entry's remaining freshness, so reflected HTTP caching headers never - * outlive the store entry. `0` means the entry is stale. - */ - const remainingTtl = entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - nowInSeconds()) : undefined + const serve = (entry: CacheEntry) => { + const remainingTtl = remainingTtlOf(entry) if (remainingTtl === 0) { - const refresh = Promise.resolve(middlewareOptions.next()) - .then(result => store.set(key, result.output, { tags, ttl, swr })) + const refresh = lock(store, key, async (waited) => { + if (waited) { + // Whoever held the lock first may have refreshed the entry already. + const current = await store.get(key) + + if (current !== undefined && remainingTtlOf(current) !== 0) { + return + } + } + await fill() + }) + + // Whatever owns background work owns the refresh from here, failures included. middlewareOptions.context['cache/waitUntil']?.(refresh) } @@ -99,6 +110,7 @@ export function cache< procedure: middlewareOptions.procedure, path: middlewareOptions.path, tags: entry.tags, + // Reflected HTTP caching headers must never outlive the store entry. ttl: remainingTtl, swr, }) @@ -106,22 +118,53 @@ export function cache< return done({ output: entry.output }) } - const result = await middlewareOptions.next() + const entry = await store.get(key) - await store.set(key, result.output, { tags, ttl, swr }) + if (entry) { + return serve(entry) + } - pluginContext?.caches.push({ - procedure: middlewareOptions.procedure, - path: middlewareOptions.path, - tags, - ttl, - swr, - }) + return lock(store, key, async (waited) => { + if (waited) { + // Whoever held the lock first may have filled the entry already. + const entry = await store.get(key) - return result + if (entry) { + return serve(entry) + } + } + + const result = await fill() + + pluginContext?.caches.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + tags, + ttl, + swr, + }) + + return result + }) } } +/** + * Runs `fn` under the store's per-key lock when it has one, so concurrent + * callers fill an entry once; otherwise every caller fills. + */ +function lock(store: CacheStore, key: unknown, fn: (waited: boolean) => Promise): Promise { + return store.lock !== undefined ? store.lock(key, fn) : fn(false) +} + +/** + * The entry's remaining freshness in seconds: `0` once it is stale and + * `undefined` when it never expires. + */ +function remainingTtlOf(entry: CacheEntry): number | undefined { + return entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - nowInSeconds()) : undefined +} + export interface RevalidateMiddlewareOptions< TInContext extends CacheContext, TInput, diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index a0a0a88c0..11e8e3b37 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -73,6 +73,14 @@ export interface CacheStore { * Invalidates every entry associated with any of the given tags. */ revalidate(options: CacheRevalidateOptions): Promise + + /** + * Runs `fn` for one caller at a time per key, so a miss is filled once + * rather than once per concurrent caller. `waited` is `true` when another + * caller held the lock first, so the entry may exist by now. Stores without + * it let every caller fill. + */ + lock?(key: unknown, fn: (waited: boolean) => Promise): Promise } /** diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index 03eb21b2b..3ad8b4f7c 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -20,3 +20,32 @@ export function encodeCacheKey(key: unknown, serializer: Public stringifyJSON(entry)).sort()]) } + +/** + * A per-key mutex for one process, backing `CacheStore.lock` where the + * backend has no atomic primitive. Callers of one key run one at a time in + * order, while other keys run independently. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class MemoryLock { + private readonly pending = new Map>() + + async run(key: string, fn: (waited: boolean) => Promise): Promise { + const previous = this.pending.get(key) + const run = () => fn(previous !== undefined) + // A failed predecessor still hands the turn on. + const current = previous === undefined ? run() : previous.then(run, run) + + this.pending.set(key, current) + + try { + return await current + } + finally { + if (this.pending.get(key) === current) { + this.pending.delete(key) + } + } + } +} diff --git a/packages/cache/tests/__shared__/store-contract.ts b/packages/cache/tests/__shared__/store-contract.ts index 202015ed2..f9a0cbc42 100644 --- a/packages/cache/tests/__shared__/store-contract.ts +++ b/packages/cache/tests/__shared__/store-contract.ts @@ -1,5 +1,5 @@ import type { CacheStore } from '../../src' -import { expect, it } from 'vitest' +import { expect, it, vi } from 'vitest' /** * The behavior every {@link CacheStore} must share, run against one adapter. @@ -69,4 +69,60 @@ export function describeCacheStoreContract(createStore: () => CacheStore): void await expect(store.get('k')).resolves.toMatchObject({ output: 'new' }) }) + + it('runs lock callbacks one key at a time, telling later callers they waited', async () => { + const store = createStore() + const order: string[] = [] + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + + const first = store.lock!('k', async (waited) => { + order.push(`first:${waited}`) + await held + return 'first' + }) + await vi.waitFor(() => expect(order).toEqual(['first:false']), { timeout: 5000 }) + + const second = store.lock!('k', async (waited) => { + order.push(`second:${waited}`) + return 'second' + }) + + // Other keys are independent of the held one. + await expect(store.lock!('other', async waited => waited)).resolves.toBe(false) + expect(order).toEqual(['first:false']) + + release() + + await expect(first).resolves.toBe('first') + await expect(second).resolves.toBe('second') + expect(order).toEqual(['first:false', 'second:true']) + }) + + it('hands the lock on when the holder throws', async () => { + const store = createStore() + let fail!: (error: Error) => void + let acquired!: () => void + const holding = new Promise((resolve) => { + acquired = resolve + }) + + const first = store.lock!('k', async () => { + acquired() + await new Promise((_, reject) => { + fail = reject + }) + }) + await holding + + const second = store.lock!('k', async waited => waited) + + fail(new Error('boom')) + + await expect(first).rejects.toThrow('boom') + await expect(second).resolves.toBe(true) + await expect(store.lock!('k', async waited => waited)).resolves.toBe(false) + }) } diff --git a/packages/cache/tests/redis-adapters-compatibility.test.ts b/packages/cache/tests/redis-adapters-compatibility.test.ts index bf6ec18e1..4df539a00 100644 --- a/packages/cache/tests/redis-adapters-compatibility.test.ts +++ b/packages/cache/tests/redis-adapters-compatibility.test.ts @@ -93,6 +93,37 @@ describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, ( expect(stale!.output).toBe('v') expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) }) + + it(`shares locks: ${source.name} β†’ ${target.name}`, async () => { + const key = `lock:${crypto.randomUUID()}` + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let acquired!: () => void + const holding = new Promise((resolve) => { + acquired = resolve + }) + + const holder = source.store.lock!(key, async () => { + acquired() + await held + }) + await holding + + let settled = false + const waiter = target.store.lock!(key, async waited => waited).then((waited) => { + settled = true + return waited + }) + + await sleep(300) + expect(settled).toBe(false) + + release() + await holder + await expect(waiter).resolves.toBe(true) + }) } } }) diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index a3b3630fc..241eb542c 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -5,11 +5,11 @@ import { experimental_WorkersCacheStore } from './workers-cache' describe('experimental_WorkersCacheStore', () => { const createPurger = () => ({ purge: vi.fn(async () => ({ success: true })), - }) + }) as any it('always misses and stores nothing', async () => { const purger = createPurger() - const store = new experimental_WorkersCacheStore(purger) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) await expect(store.get('k')).resolves.toBeUndefined() @@ -18,7 +18,7 @@ describe('experimental_WorkersCacheStore', () => { it('purges encoded tags through workers caching', async () => { const purger = createPurger() - const store = new experimental_WorkersCacheStore(purger) + const store = new experimental_WorkersCacheStore({ cache: purger }) await store.revalidate({ tags: ['planets', 'a,b'] }) @@ -34,7 +34,9 @@ describe('experimental_WorkersCacheStore', () => { it('throws a bare error when the purge fails without messages', async () => { const store = new experimental_WorkersCacheStore({ - purge: vi.fn(async () => ({ success: false })), + cache: { + purge: vi.fn(async () => ({ success: false })) as any, + }, }) await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( @@ -44,7 +46,9 @@ describe('experimental_WorkersCacheStore', () => { it('throws when the purge fails, including error messages', async () => { const store = new experimental_WorkersCacheStore({ - purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), + cache: { + purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), + }, }) await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index 13fbd125f..e207525d7 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -2,14 +2,13 @@ import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } import { encodeCacheTag, toArray } from '@orpc/shared' import * as workers from 'cloudflare:workers' -/** - * 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 experimental_WorkersCachePurger { - purge(options: { tags: string[] }): Promise<{ success: boolean, errors?: { code?: number, message?: string }[] }> +export interface experimental_WorkersCacheStoreOptions { + /** + * The Workers Caching purge surface, such as `ctx.cache`. + * + * @default cache from `cloudflare:workers` + */ + cache?: CacheContext } /** @@ -19,22 +18,13 @@ export interface experimental_WorkersCachePurger { * `set` stores nothing; `revalidate` 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 experimental_WorkersCacheStore implements CacheStore { - private readonly cache: experimental_WorkersCachePurger + private readonly cache: CacheContext - /** - * @param cache The Workers Caching purge surface, such as `ctx.cache`. - * Defaults to `cache` imported from `cloudflare:workers`. - */ - constructor(cache?: experimental_WorkersCachePurger) { - this.cache = cache ?? workers.cache + constructor(options: experimental_WorkersCacheStoreOptions = {}) { + this.cache = options.cache ?? workers.cache } async get(_key: unknown): Promise { From 685f61facbaf28e2c86c1bfbdea434f1141b0b59 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sat, 5 Sep 2026 16:47:58 +0700 Subject: [PATCH 31/35] refactor(shared): move MemoryLock out of the cache package --- apps/content/docs/helpers/cache.mdx | 2 +- packages/cache/src/adapters/memory.ts | 4 +-- packages/cache/src/adapters/vercel.ts | 4 +-- packages/cache/src/utils.ts | 29 --------------- packages/shared/src/index.ts | 1 + packages/shared/src/lock.test.ts | 51 +++++++++++++++++++++++++++ packages/shared/src/lock.ts | 31 ++++++++++++++++ 7 files changed, 88 insertions(+), 34 deletions(-) create mode 100644 packages/shared/src/lock.test.ts create mode 100644 packages/shared/src/lock.ts diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 28c6a2afd..6202659fe 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -47,7 +47,7 @@ Every duration is in seconds, matching what the underlying caches accept. 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. -Every adapter except `experimental_WorkersCacheStore` implements `lock`. The Redis, Upstash, and Bun stores hold it in Redis with `SET NX`, so it spans processes and is released when the holder finishes, or after `lockTtl` if it never does. The memory and Vercel stores hold it within the process; `MemoryLock` is exported for stores of your own. +Every adapter except `experimental_WorkersCacheStore` implements `lock`. The Redis, Upstash, and Bun stores hold it in Redis with `SET NX`, so it spans processes and is released when the holder finishes, or after `lockTtl` if it never does. The memory and Vercel stores hold it within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index f0bc7ec41..43dab6e4e 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,8 +1,8 @@ import type { Public } from '@orpc/shared' import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' import { RPCJsonSerializer } from '@orpc/client' -import { nowInSeconds } from '@orpc/shared' -import { encodeCacheKey, MemoryLock } from '../utils' +import { MemoryLock, nowInSeconds } from '@orpc/shared' +import { encodeCacheKey } from '../utils' export interface MemoryCacheStoreOptions { /** diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 20f1eec3c..2d1a27775 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -2,9 +2,9 @@ import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { nowInSeconds } from '@orpc/shared' +import { MemoryLock, nowInSeconds } from '@orpc/shared' import { getCache } from '@vercel/functions' -import { encodeCacheKey, MemoryLock } from '../utils' +import { encodeCacheKey } from '../utils' interface VercelCacheStoreEnvelope { /** diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index 3ad8b4f7c..03eb21b2b 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -20,32 +20,3 @@ export function encodeCacheKey(key: unknown, serializer: Public stringifyJSON(entry)).sort()]) } - -/** - * A per-key mutex for one process, backing `CacheStore.lock` where the - * backend has no atomic primitive. Callers of one key run one at a time in - * order, while other keys run independently. - * - * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} - */ -export class MemoryLock { - private readonly pending = new Map>() - - async run(key: string, fn: (waited: boolean) => Promise): Promise { - const previous = this.pending.get(key) - const run = () => fn(previous !== undefined) - // A failed predecessor still hands the turn on. - const current = previous === undefined ? run() : previous.then(run, run) - - this.pending.set(key, current) - - try { - return await current - } - finally { - if (this.pending.get(key) === current) { - this.pending.delete(key) - } - } - } -} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 766c5fad5..e0151061c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,6 +9,7 @@ export * from './http' export * from './id' export * from './interceptor' export * from './iterator' +export * from './lock' export * from './object' export * from './opentelemetry' export * from './plugin' diff --git a/packages/shared/src/lock.test.ts b/packages/shared/src/lock.test.ts new file mode 100644 index 000000000..2fdd716ca --- /dev/null +++ b/packages/shared/src/lock.test.ts @@ -0,0 +1,51 @@ +import { MemoryLock } from './lock' + +describe('memoryLock', () => { + it('runs callbacks one key at a time, in order, telling later callers they waited', async () => { + const lock = new MemoryLock() + const order: string[] = [] + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + + const first = lock.run('k', async (waited) => { + order.push(`first:${waited}`) + await held + return 'first' + }) + const second = lock.run('k', async (waited) => { + order.push(`second:${waited}`) + return 'second' + }) + const third = lock.run('k', async (waited) => { + order.push(`third:${waited}`) + return 'third' + }) + + // Other keys are independent of the held one. + await expect(lock.run('other', async waited => waited)).resolves.toBe(false) + expect(order).toEqual(['first:false']) + + release() + + await expect(Promise.all([first, second, third])).resolves.toEqual(['first', 'second', 'third']) + expect(order).toEqual(['first:false', 'second:true', 'third:true']) + }) + + it('hands the turn on when a callback throws, and frees the key afterwards', async () => { + const lock = new MemoryLock() + let fail!: (error: Error) => void + + const first = lock.run('k', () => new Promise((_, reject) => { + fail = reject + })) + const second = lock.run('k', async waited => waited) + + fail(new Error('boom')) + + await expect(first).rejects.toThrow('boom') + await expect(second).resolves.toBe(true) + await expect(lock.run('k', async waited => waited)).resolves.toBe(false) + }) +}) diff --git a/packages/shared/src/lock.ts b/packages/shared/src/lock.ts new file mode 100644 index 000000000..6c05b4563 --- /dev/null +++ b/packages/shared/src/lock.ts @@ -0,0 +1,31 @@ +/** + * A per-key mutex for one process. Callers of one key run one at a time in + * order, while other keys run independently. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class MemoryLock { + private readonly pending = new Map>() + + /** + * Runs `fn` once the key is free. `waited` is `true` when another caller + * held it first. + */ + async run(key: string, fn: (waited: boolean) => Promise): Promise { + const previous = this.pending.get(key) + const run = () => fn(previous !== undefined) + // A failed predecessor still hands the turn on. + const current = previous === undefined ? run() : previous.then(run, run) + + this.pending.set(key, current) + + try { + return await current + } + finally { + if (this.pending.get(key) === current) { + this.pending.delete(key) + } + } + } +} From e9f5f5910930bea49eb8150702afafa233f0b697 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 8 Sep 2026 20:31:08 +0700 Subject: [PATCH 32/35] feat(cache)!: fold get, set, and lock into one fetch method CacheStore is now fetch(key, fill, options) plus revalidate. Stores own coalescing, so concurrent callers of one key fill once and stale entries refresh once, with the refresh handed to options.waitUntil. The Redis, Upstash, and Bun stores keep entries as hashes and run two Lua scripts by sha: one reads the entry, validates its tags, and takes the lock in the same step when it is missing or stale; the other stores the entry with its tag versions snapshotted and releases the lock. A hit is one round trip and a miss two, down from two and five. --- apps/content/docs/helpers/cache.mdx | 16 +- packages/bun/src/redis-cache.test.ts | 215 +++++----- packages/bun/src/redis-cache.ts | 248 ++++++----- packages/bun/tests/__shared__/utils.ts | 41 -- ...cache-redis-adapters-compatibility.test.ts | 45 +- packages/cache/src/adapters/memory.test.ts | 131 +++--- packages/cache/src/adapters/memory.ts | 61 ++- packages/cache/src/adapters/redis.test.ts | 342 ++++----------- packages/cache/src/adapters/redis.ts | 264 +++++++----- packages/cache/src/adapters/upstash.test.ts | 128 +++--- packages/cache/src/adapters/upstash.ts | 240 ++++++----- packages/cache/src/adapters/vercel.test.ts | 94 +++-- packages/cache/src/adapters/vercel.ts | 59 ++- packages/cache/src/middleware.test.ts | 389 +++++------------- packages/cache/src/middleware.ts | 97 +---- packages/cache/src/types.ts | 40 +- packages/cache/src/utils.ts | 12 +- .../cache/tests/__shared__/store-contract.ts | 110 +++-- packages/cache/tests/__shared__/utils.ts | 40 -- .../redis-adapters-compatibility.test.ts | 45 +- packages/cloudflare/src/workers-cache.test.ts | 13 +- packages/cloudflare/src/workers-cache.ts | 22 +- packages/shared/src/lock.test.ts | 1 - packages/shared/src/lock.ts | 3 +- 24 files changed, 1178 insertions(+), 1478 deletions(-) delete mode 100644 packages/cache/tests/__shared__/utils.ts diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 6202659fe..2d8930598 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -13,24 +13,22 @@ npm install @orpc/experimental-cache@beta ## Basic Usage -The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. Stores may also implement `lock`, which runs a callback for one caller at a time per key so concurrent misses fill an entry once. 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 under the `cache/store` key, as defined by the `CacheContext` interface. +The core concept is the `CacheStore` interface: `fetch` resolves the entry under a key, filling it once when there is none even for concurrent callers, and `revalidate` invalidates entries by tag. 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 under the `cache/store` key, as defined by the `CacheContext` interface. ```ts twoslash import { MemoryCacheStore } from '@orpc/experimental-cache/memory' // ---cut--- const store = new MemoryCacheStore() -await store.set('planet:1', { id: 1, name: 'Earth' }, { +const entry = await store.fetch('planet:1', async () => ({ id: 1, name: 'Earth' }), { tags: ['planets', 'planet:1'], ttl: 60, }) -const entry = await store.get('planet:1') - -await store.revalidate({ tags: ['planets'] }) // now `get` misses +await store.revalidate({ tags: ['planets'] }) // the next `fetch` fills again ``` -An entry stays fresh for `ttl` seconds 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. +An entry stays fresh for `ttl` seconds and is retained for an extra `swr` window afterward, during which `fetch` still returns it with a past `expiresAt` while one caller refreshes it in the background. Revalidating a tag invalidates every entry associated with it, fresh or stale. ## Adapters @@ -47,7 +45,7 @@ Every duration is in seconds, matching what the underlying caches accept. 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. -Every adapter except `experimental_WorkersCacheStore` implements `lock`. The Redis, Upstash, and Bun stores hold it in Redis with `SET NX`, so it spans processes and is released when the holder finishes, or after `lockTtl` if it never does. The memory and Vercel stores hold it within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. +Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory and Vercel stores coalesce within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. @@ -200,7 +198,7 @@ const store = new WorkersCacheStore() ## 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. When the store implements `lock`, concurrent misses for one key run the handler once and the rest are served from the entry it stores. The `key`, `tags`, `ttl`, `swr`, and `enabled` options accept static values or functions of the middleware options and input. +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. Concurrent misses for one key run the handler once and the rest are served from the entry it stores. 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, it is used as given, so procedures sharing a key also share an entry. @@ -240,7 +238,7 @@ A cached entry is shared by everyone using the same key. If output depends on th ### 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 refresh once when the store implements `lock`, and once each otherwise; the cache never serves anything older than `ttl + swr`. +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 refresh once; 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 `cache/waitUntil` through the context so background refreshes can finish: diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts index 90869675d..2b6902219 100644 --- a/packages/bun/src/redis-cache.test.ts +++ b/packages/bun/src/redis-cache.test.ts @@ -1,8 +1,8 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { RedisClient } from 'bun' -import { afterAll, beforeAll, describe, expect, it, spyOn } from 'bun:test' -import { holdResult, waitFor } from '../tests/__shared__/utils' +import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test' +import { waitFor } from '../tests/__shared__/utils' import { BunRedisCacheStore } from './redis-cache' const REDIS_URL = Bun.env.REDIS_URL @@ -18,27 +18,25 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { redis.close() }) - function createTestingStore( - options: ConstructorParameters[1] = {}, - client: RedisClient = redis, - ) { + function createTestingStore(options: ConstructorParameters[1] = {}) { const prefix = `orpc-bun-redis-cache-store-${crypto.randomUUID()}:` - return { store: new BunRedisCacheStore(client, { prefix, ...options }), prefix } + return { store: new BunRedisCacheStore(redis, { prefix, ...options }), prefix } } - it('round-trips outputs with their tags and expiresAt, including undefined', async () => { + it('fills a miss once, then serves the entry with its tags and expiresAt', async () => { const { store } = createTestingStore() + const fill = mock(async () => ({ nested: [1, 2] })) - await store.set('k', { nested: [1, 2] }, { tags: ['t'], ttl: 120 }) + const first = await store.fetch('k', fill, { tags: ['t'], ttl: 120 }) + expect(first.output).toEqual({ nested: [1, 2] }) + expect(first.tags).toEqual(['t']) + expect(first.expiresAt).toBeGreaterThan(nowInSeconds()) - const entry = await store.get('k') - expect(entry!.output).toEqual({ nested: [1, 2] }) - expect(entry!.tags).toEqual(['t']) - expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + await expect(store.fetch('k', fill, { tags: ['t'], ttl: 120 })).resolves.toEqual(first) + expect(fill).toHaveBeenCalledTimes(1) - await store.set('u', undefined) - await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) - await expect(store.get('unknown')).resolves.toBeUndefined() + await store.fetch('u', async () => undefined) + await expect(store.fetch('u', async () => 'refilled')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) }) it('preserves Date, Map, Set, and BigInt outputs', async () => { @@ -50,23 +48,21 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { big: 123n, } - await store.set('k', output) - await expect(store.get('k')).resolves.toMatchObject({ output }) + await store.fetch('k', async () => output) + await expect(store.fetch('k', async () => 'refilled')).resolves.toMatchObject({ output }) }) - it('invalidates entries by any of their tags, and keeps ones set afterwards', async () => { + it('invalidates entries by any of their tags, and keeps ones filled afterwards', async () => { const { store } = createTestingStore() - await store.set('multi', 'v', { tags: ['a', 'b'] }) - await store.set('other', 'v', { tags: ['c'] }) + await store.fetch('multi', async () => 'v', { tags: ['a', 'b'] }) + await store.fetch('other', async () => 'v', { tags: ['c'] }) await store.revalidate({ tags: ['a', 'b'] }) - await expect(store.get('multi')).resolves.toBeUndefined() - await expect(store.get('other')).resolves.toBeDefined() - - await store.set('multi', 'new', { tags: ['a'] }) - await expect(store.get('multi')).resolves.toMatchObject({ output: 'new' }) + await expect(store.fetch('multi', async () => 'new', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) + await expect(store.fetch('other', async () => 'refilled', { tags: ['c'] })).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch('multi', async () => 'newer', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) }) it('supports a custom serializer', async () => { @@ -75,135 +71,120 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { const deserializeSpy = spyOn(serializer, 'deserialize') const { store } = createTestingStore({ serializer }) - await store.set('k', { a: 1 }) + await store.fetch('k', async () => ({ a: 1 })) - await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) expect(serializeSpy).toHaveBeenCalled() expect(deserializeSpy).toHaveBeenCalled() }) - it('evicts at ttl without swr, and serves stale within the swr window', async () => { + it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { const { store } = createTestingStore() - await store.set('no-swr', 'v', { ttl: 1 }) - await store.set('swr', 'v', { ttl: 1, swr: 10 }) + await store.fetch('no-swr', async () => 'v', { ttl: 1 }) + await store.fetch('swr', async () => 'v', { ttl: 1, swr: 10 }) await sleep(1500) - await expect(store.get('no-swr')).resolves.toBeUndefined() + await expect(store.fetch('no-swr', async () => 'refilled', { ttl: 1 })).resolves.toMatchObject({ output: 'refilled' }) + + const waitUntil = mock((_promise: Promise) => {}) + const stale = await store.fetch('swr', async () => 'fresh', { ttl: 1, swr: 10, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(nowInSeconds()) - const stale = await store.get('swr') - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + const fresh = await store.fetch('swr', async () => 'other', { ttl: 1, swr: 10 }) + expect(fresh.output).toBe('fresh') + expect(fresh.expiresAt).toBeGreaterThan(stale.expiresAt!) }, { timeout: 20_000 }) - it('stores entries and tag counters under the prefixed key families, defaulting to no prefix', async () => { + it('stores entries as hashes and tag counters under the prefixed key families, locking while filling', async () => { const { store, prefix } = createTestingStore() - await store.set('k', 'v', { tags: ['t'] }) + await store.fetch('k', async () => { + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(true) + return 'v' + }, { tags: ['t'] }) await store.revalidate({ tags: ['t'] }) - await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(true) + await expect(redis.send('TYPE', [`${prefix}e:k`])).resolves.toBe('hash') await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(true) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(false) const unprefixed = new BunRedisCacheStore(redis) const key = crypto.randomUUID() - - await unprefixed.set(key, 'v') - + await unprefixed.fetch(key, async () => 'v') await expect(redis.exists(`e:${key}`)).resolves.toBe(true) - await expect(unprefixed.get(key)).resolves.toMatchObject({ output: 'v' }) }) it('treats tags missing from the snapshot as version zero', async () => { const { store, prefix } = createTestingStore() - await redis.set(`${prefix}e:k`, JSON.stringify({ output: { json: 'v' }, tags: ['t'], tagVersions: {} })) + await redis.send('HSET', [`${prefix}e:k`, 'output', JSON.stringify({ body: { json: 'v' } }), 'tags', '["t"]', 'tagVersions', '{}']) - await expect(store.get('k')).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) }) - it('encodes non-string keys stably', async () => { - const { store } = createTestingStore() + it('reloads scripts the server dropped, and rethrows other script errors', async () => { + const { store, prefix } = createTestingStore() - await store.set([['planet', 'find'], { b: 2, a: 1 }], 'v') + await store.fetch('k', async () => 'v') + await redis.send('SCRIPT', ['FLUSH']) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: '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 redis.send('HSET', [`${prefix}e:broken`, 'output', '{}', 'tags', 'not json', 'tagVersions', '{}']) + await expect(store.fetch('broken', async () => 'v')).rejects.toThrow() }) - it('drops an entry whose tag versions were read before a racing revalidation', async () => { - const { client, read, release } = holdResult(redis, 'mget') - const { store, prefix } = createTestingStore({}, client) - - const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release - await read // versions are read by now - await store.revalidate({ tags: ['t'] }) - release() - await set - - await expect(store.get('k')).resolves.toBeUndefined() - await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(false) - }) - - it('stays consistent under concurrent sets, gets, and a revalidation on a shared tag', async () => { + it('encodes non-string keys stably', async () => { const { store } = createTestingStore() - const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) - - await Promise.all([ - ...keys.map(key => store.set(key, key, { tags: ['t'] })), - store.revalidate({ tags: ['t'] }), - ...keys.map(key => store.get(key)), - ]) - - // Entries snapshotted before the revalidation miss, the rest hit with their own output. - const entries = await Promise.all(keys.map(key => store.get(key))) - entries.forEach((entry, index) => { - if (entry !== undefined) { - expect(entry.output).toBe(keys[index]) - } - }) - await store.revalidate({ tags: ['t'] }) + await store.fetch([['planet', 'find'], { b: 2, a: 1 }], async () => 'v') - await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) + await expect(store.fetch([['planet', 'find'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch([['planet', 'find'], { a: 1, b: 3 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) }) - it('runs lock callbacks one key at a time, handing on after failures', async () => { + it('fills once for concurrent callers of one key, and lets a waiter fill when the holder failed', async () => { const { store } = createTestingStore() - const order: string[] = [] - let release!: () => void - const held = new Promise((resolve) => { - release = resolve + let finish!: (output: string) => void + const fill = mock(() => new Promise((resolve) => { + finish = resolve + })) + + const fetches = Promise.all([store.fetch('k', fill), store.fetch('k', fill), store.fetch('k', fill)]) + await waitFor(() => expect(fill).toHaveBeenCalledTimes(1), { timeout: 5000 }) + finish('v') + + const entries = await fetches + expect(entries.map(entry => entry.output)).toEqual(['v', 'v', 'v']) + expect(fill).toHaveBeenCalledTimes(1) + + let fail!: (error: Error) => void + let started!: () => void + const holding = new Promise((resolve) => { + started = resolve }) - - const first = store.lock('k', async (waited) => { - order.push(`first:${waited}`) - await held - return 'first' - }) - await waitFor(() => expect(order).toEqual(['first:false']), { timeout: 5000 }) - - const second = store.lock('k', async (waited) => { - order.push(`second:${waited}`) - return 'second' + const first = store.fetch('failing', () => { + started() + return new Promise((_, reject) => { + fail = reject + }) }) - await expect(store.lock('other', async waited => waited)).resolves.toBe(false) - expect(order).toEqual(['first:false']) + await holding + const second = store.fetch('failing', async () => 'fresh') + fail(new Error('handler down')) - release() - await expect(first).resolves.toBe('first') - await expect(second).resolves.toBe('second') - expect(order).toEqual(['first:false', 'second:true']) - - await expect(store.lock('k', async () => { - throw new Error('boom') - })).rejects.toThrow('boom') - await expect(store.lock('k', async waited => waited)).resolves.toBe(false) + await expect(first).rejects.toThrow('handler down') + await expect(second).resolves.toMatchObject({ output: 'fresh' }) }) it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { - const { store, prefix } = createTestingStore({ lockTtl: 1 }) + const { store: holderStore, prefix } = createTestingStore({ lockTtl: 1 }) + const waiterStore = new BunRedisCacheStore(redis, { prefix }) let release!: () => void const held = new Promise((resolve) => { release = resolve @@ -213,22 +194,24 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { takenOver = resolve }) - // Holds past its ttl, until the waiter has taken the lock over. - const holder = store.lock('k', () => takeover) + const holder = holderStore.fetch('k', async () => { + await takeover + return 'holder' + }) await waitFor(async () => expect(await redis.exists(`${prefix}l:k`)).toBe(true), { timeout: 5000 }) - const waiter = store.lock('k', async (waited) => { + const waiter = waiterStore.fetch('k', async () => { takenOver() await held - return waited + return 'waiter' }) - await holder - // The holder's release must leave the waiter's lock alone. + await expect(holder).resolves.toMatchObject({ output: 'holder' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(true) release() - await expect(waiter).resolves.toBe(true) + await expect(waiter).resolves.toMatchObject({ output: 'waiter' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(false) + await expect(holderStore.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'waiter' }) }, { timeout: 20_000 }) }) diff --git a/packages/bun/src/redis-cache.ts b/packages/bun/src/redis-cache.ts index 754e1d689..a7badf623 100644 --- a/packages/bun/src/redis-cache.ts +++ b/packages/bun/src/redis-cache.ts @@ -1,10 +1,80 @@ -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '@orpc/experimental-cache' import type { Public } from '@orpc/shared' import type { RedisClient } from 'bun' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { encodeCacheKey } from '@orpc/experimental-cache' import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' +/** + * Reads the entry as `[output, tags, expiresAt, shouldFill]`, dropping it when + * a tag was revalidated since it was stored. A missing or stale entry also + * takes the lock, and `shouldFill` reports whether this caller got it. + */ +const FETCH_SCRIPT = ` +local fields = redis.call('HMGET', KEYS[1], 'output', 'tags', 'tagVersions', 'expiresAt') +local output, tags, versions, expiresAt = fields[1], fields[2], fields[3], fields[4] + +if output and tags then + local names = cjson.decode(tags) + local snapshot = cjson.decode(versions) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[3] .. name + end + local live = redis.call('MGET', unpack(keys)) + for i, name in ipairs(names) do + if tonumber(live[i] or 0) ~= (snapshot[name] or 0) then + redis.call('DEL', KEYS[1]) + output = false + break + end + end +end + +local stale = output and expiresAt and tonumber(expiresAt) <= tonumber(ARGV[4]) +local acquired = false +if not output or stale then + acquired = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) and true or false +end + +return { output or false, tags or false, expiresAt or false, acquired } +` + +/** + * Stores the entry with its tag versions snapshotted in the same step, then + * releases the caller's lock. + */ +const STORE_SCRIPT = ` +redis.call('DEL', KEYS[1]) +redis.call('HSET', KEYS[1], 'output', ARGV[2]) + +if ARGV[3] ~= '' then + local names = cjson.decode(ARGV[3]) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[6] .. name + end + local live = redis.call('MGET', unpack(keys)) + local snapshot = {} + for i, name in ipairs(names) do + snapshot[name] = tonumber(live[i] or 0) + end + redis.call('HSET', KEYS[1], 'tags', ARGV[3], 'tagVersions', cjson.encode(snapshot)) +end + +if ARGV[4] ~= '' then + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[4]) +end + +if ARGV[5] ~= '' then + redis.call('PEXPIRE', KEYS[1], ARGV[5]) +end + +if redis.call('GET', KEYS[2]) == ARGV[1] then + redis.call('DEL', KEYS[2]) +end +` + /** * Deletes the lock only while it still holds the caller's token, leaving one * that expired and was taken over alone. @@ -16,19 +86,6 @@ end return 0 ` -interface BunRedisCacheStoreEnvelope { - /** - * The cached output, encoded with the store's serializer. - */ - output: unknown - tags?: readonly string[] - /** - * Tag version counters snapshotted at set time. - */ - tagVersions?: Record - expiresAt?: number | undefined -} - export interface BunRedisCacheStoreOptions { /** * The prefix to use for Redis keys. @@ -55,132 +112,127 @@ export interface BunRedisCacheStoreOptions { /** * Cache store adapter for Bun's built-in Redis client with tag-based - * invalidation. Shares its key and envelope format with `RedisCacheStore`, - * so both can serve the same database. Entries are retained for `ttl + swr` - * via `EX` expiry; tag counters have no expiry since expiring one would + * invalidation. Shares its key and entry format with `RedisCacheStore`, so + * both can serve the same database. Entries are hashes retained for + * `ttl + swr`; tag counters have no expiry since expiring one would * resurrect stale entries. Revalidated entries are removed lazily on the - * next `get` of their key. Locks are held in Redis with `SET NX`, so they - * span processes. + * next `fetch` of their key. Concurrent callers of one key are coalesced + * through a lock taken in the same script that reads the entry, so it spans + * processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class BunRedisCacheStore implements CacheStore { private readonly prefix: string + private readonly tagPrefix: string private readonly serializer: Public private readonly lockTtl: number - /** - * Key encoding has no serializer option, so one is built here rather than - * per call by {@link encodeCacheKey}. - */ private readonly keySerializer = new RPCJsonSerializer() + private readonly scriptShas = new Map() + constructor( private readonly redis: RedisClient, options: BunRedisCacheStoreOptions = {}, ) { this.prefix = options.prefix ?? '' + this.tagPrefix = `${this.prefix}t:` this.serializer = options.serializer ?? new RPCSerializer() this.lockTtl = options.lockTtl ?? 10 } - async get(key: unknown): Promise { - const entryKey = this.entryKey(key) - const raw = await this.redis.get(entryKey) - - if (raw === null) { - return undefined - } - - const envelope = JSON.parse(raw) as BunRedisCacheStoreEnvelope - - if (envelope.tags?.length) { - const versions = await this.redis.mget(...envelope.tags.map(tag => this.tagKey(tag))) + async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { + const encodedKey = encodeCacheKey(key, this.keySerializer) + const entryKey = `${this.prefix}e:${encodedKey}` + const lockKey = `${this.prefix}l:${encodedKey}` + const token = crypto.randomUUID() - const revalidated = envelope.tags.some( - (tag, index) => Number(versions[index] ?? 0) !== (envelope.tagVersions?.[tag] ?? 0), - ) + while (true) { + const [output, tags, expiresAt, shouldFill] = await this.run( + FETCH_SCRIPT, + [entryKey, lockKey], + [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], + ) as [string | null, string | null, string | null, number | null] + + if (output !== null) { + const entry: CacheEntry = { + output: this.serializer.deserialize(JSON.parse(output).body), + tags: tags !== null ? JSON.parse(tags) : undefined, + expiresAt: expiresAt !== null ? Number(expiresAt) : undefined, + } + + if (shouldFill) { + const refresh = this.store(entryKey, lockKey, token, fill, options) + options.waitUntil?.(refresh) + } + + return entry + } - if (revalidated) { - await this.redis.del(entryKey) - return undefined + if (shouldFill) { + return this.store(entryKey, lockKey, token, fill, options) } - } - return { - output: this.serializer.deserialize(envelope.output as any), - tags: envelope.tags, - expiresAt: envelope.expiresAt, + await sleep(50) } } - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const serialized = this.serializer.serialize(output) + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await Promise.all(tags.map(tag => this.redis.incr(`${this.tagPrefix}${tag}`))) + } - const tags = options?.tags + private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { + let output: unknown + let serialized: string - let tagVersions: Record | undefined - if (tags?.length) { - const versions = await this.redis.mget(...tags.map(tag => this.tagKey(tag))) - tagVersions = {} - tags.forEach((tag, index) => { - tagVersions![tag] = Number(versions[index] ?? 0) - }) + try { + output = await fill() + serialized = stringifyJSON({ body: this.serializer.serialize(output) }) } - - const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - - const envelope: BunRedisCacheStoreEnvelope = { - output: serialized, - tags, - tagVersions, - expiresAt, + catch (error) { + await this.run(RELEASE_LOCK_SCRIPT, [lockKey], [token]) + throw error } - const entryKey = this.entryKey(key) - const value = stringifyJSON(envelope) + const tags = options.tags?.length ? options.tags : undefined + const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - if (retention !== undefined) { - await this.redis.set(entryKey, value, 'EX', retention) - } - else { - await this.redis.set(entryKey, value) - } - } + await this.run(STORE_SCRIPT, [entryKey, lockKey], [ + token, + serialized, + tags !== undefined ? stringifyJSON(tags) : '', + expiresAt !== undefined ? String(expiresAt) : '', + retention !== undefined ? String(Math.ceil(retention * 1000)) : '', + this.tagPrefix, + ]) - async revalidate({ tags }: CacheRevalidateOptions): Promise { - // The client pipelines these into a single round trip. - await Promise.all(tags.map(tag => this.redis.incr(this.tagKey(tag)))) + return { output, tags, expiresAt } } - async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { - const lockKey = this.lockKey(key) - const token = crypto.randomUUID() - let waited = false - - while (await this.redis.set(lockKey, token, 'PX', String(this.lockTtl * 1000), 'NX') === null) { - waited = true - await sleep(50) // until the holder releases, or its ttl passes - } - + private async run(script: string, keys: string[], args: string[]): Promise { try { - return await fn(waited) + return await this.evalSha(script, keys, args) } - finally { - await this.redis.send('EVAL', [RELEASE_LOCK_SCRIPT, '1', lockKey, token]) + catch (error) { + if (error instanceof Error && error.message.startsWith('NOSCRIPT')) { + this.scriptShas.delete(script) + return await this.evalSha(script, keys, args) + } + + throw error } } - private entryKey(key: unknown): string { - return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` - } + private async evalSha(script: string, keys: string[], args: string[]): Promise { + let sha = this.scriptShas.get(script) - private tagKey(tag: string): string { - return `${this.prefix}t:${tag}` - } + if (sha === undefined) { + sha = await this.redis.send('SCRIPT', ['LOAD', script]) as string + this.scriptShas.set(script, sha) + } - private lockKey(key: unknown): string { - return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + return await this.redis.send('EVALSHA', [sha, String(keys.length), ...keys, ...args]) } } diff --git a/packages/bun/tests/__shared__/utils.ts b/packages/bun/tests/__shared__/utils.ts index 2c99f9472..a9bd8fd52 100644 --- a/packages/bun/tests/__shared__/utils.ts +++ b/packages/bun/tests/__shared__/utils.ts @@ -13,44 +13,3 @@ export async function waitFor(fn: () => void, { timeout = 1000, interval = 50 } } throw lastError } - -/** - * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Await `read` to know the first held - * call has completed, then run a racing operation before `release` to land it - * between that read and whatever follows it. - */ -export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - - let settle!: () => void - const read = new Promise((resolve) => { - settle = resolve - }) - - const proxy = new Proxy(client, { - get(target, prop) { - const value = Reflect.get(target, prop) - - if (typeof value !== 'function') { - return value - } - - if (prop !== method) { - return value.bind(target) - } - - return async (...args: unknown[]) => { - const result = await value.apply(target, args) - settle() - await gate - return result - } - }, - }) - - return { client: proxy, read, release } -} diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts index c6f4abd91..4e636e530 100644 --- a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -49,49 +49,47 @@ describe.concurrent('cache redis adapters compatibility', async () => { const tag = `tag:${crypto.randomUUID()}` const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } - await source.store.set([['planet', 'find'], { b: 2, id }], output, { tags: [tag], ttl: 60 }) + await source.store.fetch([['planet', 'find'], { b: 2, id }], async () => output, { tags: [tag], ttl: 60 }) - // Structurally equal keys resolve the same entry across adapters, whatever the property order. - const entry = await target.store.get([['planet', 'find'], { id, b: 2 }]) - expect(entry!.output).toEqual(output) - expect(entry!.tags).toEqual([tag]) - expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + const entry = await target.store.fetch([['planet', 'find'], { id, b: 2 }], async () => 'refilled', { tags: [tag], ttl: 60 }) + expect(entry.output).toEqual(output) + expect(entry.tags).toEqual([tag]) + expect(entry.expiresAt).toBeGreaterThan(nowInSeconds()) await target.store.revalidate({ tags: [tag] }) - await expect(source.store.get([['planet', 'find'], { b: 2, id }])).resolves.toBeUndefined() + await expect(source.store.fetch([['planet', 'find'], { b: 2, id }], async () => 'refilled', { tags: [tag] })).resolves.toMatchObject({ output: 'refilled' }) }, { timeout: 20_000 }) it(`shares tag counters: ${source.name} β†’ ${target.name}`, async () => { const key = `counter:${crypto.randomUUID()}` const tag = `tag:${crypto.randomUUID()}` - await source.store.set(key, 'v1', { tags: [tag] }) + await source.store.fetch(key, async () => 'v1', { tags: [tag] }) await target.store.revalidate({ tags: [tag] }) - await expect(source.store.get(key)).resolves.toBeUndefined() - // A snapshot taken by one adapter stays valid for the other until the next revalidation. - await target.store.set(key, 'v2', { tags: [tag] }) - await expect(source.store.get(key)).resolves.toMatchObject({ output: 'v2' }) + await expect(target.store.fetch(key, async () => 'v2', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + await expect(source.store.fetch(key, async () => 'v3', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) await source.store.revalidate({ tags: [tag] }) - await expect(target.store.get(key)).resolves.toBeUndefined() + await expect(target.store.fetch(key, async () => 'v4', { tags: [tag] })).resolves.toMatchObject({ output: 'v4' }) }, { timeout: 20_000 }) it(`shares retention: ${source.name} β†’ ${target.name}`, async () => { const noSwr = `no-swr:${crypto.randomUUID()}` const swr = `swr:${crypto.randomUUID()}` - await source.store.set(noSwr, 'v', { ttl: 1 }) - await source.store.set(swr, 'v', { ttl: 1, swr: 10 }) + await source.store.fetch(noSwr, async () => 'v', { ttl: 1 }) + await source.store.fetch(swr, async () => 'v', { ttl: 1, swr: 10 }) await sleep(1500) - await expect(target.store.get(noSwr)).resolves.toBeUndefined() + await expect(target.store.fetch(noSwr, async () => 'refilled', { ttl: 1 })).resolves.toMatchObject({ output: 'refilled' }) - const stale = await target.store.get(swr) - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + const waitUntil = (_promise: Promise) => {} + const stale = await target.store.fetch(swr, async () => 'refilled', { ttl: 1, swr: 10, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(nowInSeconds()) }, { timeout: 20_000 }) it(`shares locks: ${source.name} β†’ ${target.name}`, async () => { @@ -105,16 +103,17 @@ describe.concurrent('cache redis adapters compatibility', async () => { acquired = resolve }) - const holder = source.store.lock!(key, async () => { + const holder = source.store.fetch(key, async () => { acquired() await held + return 'held' }) await holding let settled = false - const waiter = target.store.lock!(key, async waited => waited).then((waited) => { + const waiter = target.store.fetch(key, async () => 'refilled').then((entry) => { settled = true - return waited + return entry }) await sleep(300) @@ -122,7 +121,7 @@ describe.concurrent('cache redis adapters compatibility', async () => { release() await holder - await expect(waiter).resolves.toBe(true) + await expect(waiter).resolves.toMatchObject({ output: 'held' }) }, { timeout: 20_000 }) } } diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts index 95551bc32..84dab9dd9 100644 --- a/packages/cache/src/adapters/memory.test.ts +++ b/packages/cache/src/adapters/memory.test.ts @@ -17,22 +17,22 @@ describe('memoryCacheStore', () => { 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 store.fetch([['planet', 'find'], { b: 2, a: 1 }], async () => '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() + await expect(store.fetch([['planet', 'find'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch([['planet', 'find'], { a: 1, b: 3 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) + await expect(store.fetch([['planet', 'list'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) }) it('encodes complex key values, ignoring unsupported ones like blobs', async () => { const store = new MemoryCacheStore() - 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.fetch({ date: new Date(1), big: 1n }, async () => 'v') + await expect(store.fetch({ big: 1n, date: new Date(1) }, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch({ big: 2n, date: new Date(1) }, async () => 'other')).resolves.toMatchObject({ output: 'other' }) - await store.set({ file: new Blob(['a']), id: 1 }, 'blobbed') - await expect(store.get({ file: new Blob(['b']), id: 1 })).resolves.toMatchObject({ output: 'blobbed' }) + await store.fetch({ file: new Blob(['a']), id: 1 }, async () => 'blobbed') + await expect(store.fetch({ file: new Blob(['b']), id: 1 }, async () => 'other')).resolves.toMatchObject({ output: 'blobbed' }) }) it('supports a custom key serializer', async () => { @@ -40,83 +40,104 @@ describe('memoryCacheStore', () => { const serializeSpy = vi.spyOn(serializer, 'serialize') const store = new MemoryCacheStore({ serializer }) - await store.set({ id: 1 }, 'v') + await store.fetch({ id: 1 }, async () => 'v') - await expect(store.get({ id: 1 })).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch({ id: 1 }, async () => 'other')).resolves.toMatchObject({ output: 'v' }) expect(serializeSpy).toHaveBeenCalled() }) - it('returns fresh entries with a future expiresAt, then evicts at ttl without swr', async () => { + it('returns fresh entries with a future expiresAt, then fills again at ttl without swr', async () => { const store = new MemoryCacheStore() - await store.set('k', 'v', { ttl: 1 }) - await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + await expect(store.fetch('k', async () => 'v', { ttl: 1 })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) vi.setSystemTime(999) - await expect(store.get('k')).resolves.toBeDefined() + await expect(store.fetch('k', async () => 'other', { ttl: 1 })).resolves.toMatchObject({ output: 'v' }) vi.setSystemTime(1000) - await expect(store.get('k')).resolves.toBeUndefined() + await expect(store.fetch('k', async () => 'other', { ttl: 1 })).resolves.toEqual({ output: 'other', tags: undefined, expiresAt: 2 }) }) - it('returns stale entries within the swr window, then evicts', async () => { + it('serves stale entries within swr while one caller refreshes them in the background', async () => { const store = new MemoryCacheStore() - - await store.set('k', 'v', { ttl: 1, swr: 1 }) + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) vi.setSystemTime(1200) // past ttl, within swr - await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const waitUntil = vi.fn() - vi.setSystemTime(2000) // past ttl + swr - await expect(store.get('k')).resolves.toBeUndefined() + await expect(store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + await expect(store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + expect(waitUntil).toHaveBeenCalledTimes(2) + + finish('fresh') + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(fill).toHaveBeenCalledTimes(1) // the second stale hit found the refreshed entry + + await expect(store.fetch('k', fill, { ttl: 1, swr: 1 })).resolves.toEqual({ output: 'fresh', tags: undefined, expiresAt: 2 }) }) - it('invalidates stale entries too, and overwrites replace tags and expiry', async () => { + it('leaves a failed refresh to waitUntil and keeps serving the stale entry', async () => { const store = new MemoryCacheStore() + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) - await store.set('stale', 'v', { tags: ['a'], ttl: 1, swr: 1 }) - await store.set('k', 'old', { tags: ['old'], ttl: 1 }) - await store.set('k', 'new', { tags: ['new'] }) + vi.setSystemTime(1200) + const waitUntil = vi.fn() + const fill = vi.fn(async () => { + throw new Error('handler down') + }) - vi.setSystemTime(1200) // 'stale' is now stale - await store.revalidate({ tags: ['a', 'old'] }) + await expect(store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil })).resolves.toMatchObject({ output: 'v' }) + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') - await expect(store.get('stale')).resolves.toBeUndefined() - await expect(store.get('k')).resolves.toEqual({ output: 'new', tags: ['new'], expiresAt: undefined }) + await expect(store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil })).resolves.toMatchObject({ output: 'v' }) + await expect(waitUntil.mock.calls[1]![0]).rejects.toThrow('handler down') + expect(fill).toHaveBeenCalledTimes(2) }) -}) -describe('memoryCacheStore concurrency', () => { - it('applies concurrent sets and revalidations in call order', async () => { + it('lets a waiting refresh fill when the first one failed', async () => { const store = new MemoryCacheStore() - - await Promise.all([ - store.set('before', 'v', { tags: ['t'] }), - store.revalidate({ tags: ['t'] }), - store.set('after', 'v', { tags: ['t'] }), - ]) - - await expect(store.get('before')).resolves.toBeUndefined() - await expect(store.get('after')).resolves.toMatchObject({ output: 'v' }) + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) + + vi.setSystemTime(1200) + let fail!: (error: Error) => void + const fill = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockResolvedValue('fresh') + const waitUntil = vi.fn() + + await store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil }) + await store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil }) + fail(new Error('handler down')) + + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') + await waitUntil.mock.calls[1]![0] + expect(fill).toHaveBeenCalledTimes(2) + + await expect(store.fetch('k', fill, { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'fresh' }) }) - it('misses consistently across concurrent gets of an invalidated entry', async () => { + it('evicts past ttl + swr, and revalidation drops stale entries too', async () => { const store = new MemoryCacheStore() - await store.set('k', 'v', { tags: ['t'] }) - await store.revalidate({ tags: ['t'] }) - - await expect(Promise.all([store.get('k'), store.get('k'), store.get('k')])).resolves.toEqual([undefined, undefined, undefined]) - }) + await store.fetch('evicted', async () => 'v', { ttl: 1, swr: 1 }) + await store.fetch('stale', async () => 'v', { tags: ['a'], ttl: 1, swr: 1 }) + await store.fetch('k', async () => 'old', { tags: ['old'], ttl: 1 }) - it('keeps the last of concurrent sets to the same key', async () => { - const store = new MemoryCacheStore() + vi.setSystemTime(1000) // 'k' expired without swr, so it is filled again with new tags + await expect(store.fetch('k', async () => 'new', { tags: ['new'] })).resolves.toEqual({ output: 'new', tags: ['new'], expiresAt: undefined }) - await Promise.all([ - store.set('k', 'first', { ttl: 1 }), - store.set('k', 'last', { tags: ['t'] }), - ]) + vi.setSystemTime(1200) // 'stale' and 'evicted' are stale + await store.revalidate({ tags: ['a', 'old'] }) + await expect(store.fetch('stale', async () => 'refilled', { tags: ['a'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.fetch('k', async () => 'refilled', { tags: ['new'] })).resolves.toMatchObject({ output: 'new' }) - await expect(store.get('k')).resolves.toEqual({ output: 'last', tags: ['t'], expiresAt: undefined }) + vi.setSystemTime(2000) // past ttl + swr + await expect(store.fetch('evicted', async () => 'refilled')).resolves.toMatchObject({ output: 'refilled' }) }) }) diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts index 43dab6e4e..e9deb2c15 100644 --- a/packages/cache/src/adapters/memory.ts +++ b/packages/cache/src/adapters/memory.ts @@ -1,8 +1,8 @@ import type { Public } from '@orpc/shared' -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer } from '@orpc/client' import { MemoryLock, nowInSeconds } from '@orpc/shared' -import { encodeCacheKey } from '../utils' +import { encodeCacheKey, isCacheEntryStale } from '../utils' export interface MemoryCacheStoreOptions { /** @@ -28,8 +28,8 @@ interface MemoryCacheStoreEntry { /** * 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. - * Locks are held within the process. + * revalidated entries are removed lazily on the next `fetch` of their key, + * and concurrent callers of one key are coalesced within the process. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -43,8 +43,39 @@ export class MemoryCacheStore implements CacheStore { this.serializer = options.serializer ?? new RPCJsonSerializer() } - async get(key: unknown): Promise { + async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { const encodedKey = encodeCacheKey(key, this.serializer) + const entry = this.read(encodedKey) + + if (entry === undefined) { + return this.memoryLock.run(encodedKey, async (waited) => { + const current = waited ? this.read(encodedKey) : undefined + return current ?? this.write(encodedKey, await fill(), options) + }) + } + + if (isCacheEntryStale(entry)) { + const refresh = this.memoryLock.run(encodedKey, async (waited) => { + const current = waited ? this.read(encodedKey) : undefined + + if (current === undefined || isCacheEntryStale(current)) { + this.write(encodedKey, await fill(), options) + } + }) + + options.waitUntil?.(refresh) + } + + return entry + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + for (const tag of tags) { + this.tagVersions.set(tag, (this.tagVersions.get(tag) ?? 0) + 1) + } + } + + private read(encodedKey: string): CacheEntry | undefined { const entry = this.entries.get(encodedKey) if (!entry) { @@ -72,27 +103,19 @@ export class MemoryCacheStore implements CacheStore { } } - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const tags = options?.tags - const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const evictAt = expiresAt !== undefined ? expiresAt + (options?.swr ?? 0) : undefined + private write(encodedKey: string, output: unknown, options: CacheFetchOptions): CacheEntry { + const tags = options.tags + const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const evictAt = expiresAt !== undefined ? expiresAt + (options.swr ?? 0) : undefined - this.entries.set(encodeCacheKey(key, this.serializer), { + this.entries.set(encodedKey, { output, tags, tagVersions: tags?.map(tag => this.tagVersions.get(tag) ?? 0), expiresAt, evictAt, }) - } - - async revalidate({ tags }: CacheRevalidateOptions): Promise { - for (const tag of tags) { - this.tagVersions.set(tag, (this.tagVersions.get(tag) ?? 0) + 1) - } - } - async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { - return this.memoryLock.run(encodeCacheKey(key, this.serializer), fn) + return { output, tags, expiresAt } } } diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts index 97508d4ad..5dbf12b92 100644 --- a/packages/cache/src/adapters/redis.test.ts +++ b/packages/cache/src/adapters/redis.test.ts @@ -2,7 +2,6 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { createClient } from 'redis' import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' -import { holdResult } from '../../tests/__shared__/utils' import { RedisCacheStore } from './redis' const REDIS_URL = process.env.REDIS_URL @@ -21,9 +20,10 @@ describe.concurrent('redis cache store integration', { function createTestingStore( options: ConstructorParameters[1] = {}, + client = redis, ) { - const prefix = `orpc-redis-cache-store-${crypto.randomUUID()}:` - return { store: new RedisCacheStore(redis, { prefix, ...options }), prefix } + const prefix = `orpc-rediscachestore-${crypto.randomUUID()}:` + return { store: new RedisCacheStore(client, { prefix, ...options }), prefix } } describeCacheStoreContract(() => createTestingStore().store) @@ -34,36 +34,77 @@ describe.concurrent('redis cache store integration', { const deserializeSpy = vi.spyOn(serializer, 'deserialize') const { store } = createTestingStore({ serializer }) - await store.set('k', { a: 1 }) + await store.fetch('k', async () => ({ a: 1 })) - await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) expect(serializeSpy).toHaveBeenCalled() expect(deserializeSpy).toHaveBeenCalled() }) - it('evicts at ttl without swr, and serves stale within the swr window', async () => { + it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { const { store } = createTestingStore() - await store.set('no-swr', 'v', { ttl: 1 }) - await store.set('swr', 'v', { ttl: 1, swr: 10 }) + await store.fetch('no-swr', async () => 'v', { ttl: 1 }) + await store.fetch('swr', async () => 'v', { ttl: 1, swr: 10 }) await sleep(1500) - await expect(store.get('no-swr')).resolves.toBeUndefined() + await expect(store.fetch('no-swr', async () => 'refilled', { ttl: 1 })).resolves.toMatchObject({ output: 'refilled' }) - const stale = await store.get('swr') - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + const waitUntil = vi.fn() + const stale = await store.fetch('swr', async () => 'fresh', { ttl: 1, swr: 10, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + const fresh = await store.fetch('swr', async () => 'other', { ttl: 1, swr: 10 }) + expect(fresh.output).toBe('fresh') + expect(fresh.expiresAt).toBeGreaterThan(stale.expiresAt!) }) - it('stores entries and tag counters under the prefixed key families', async () => { + it('stores entries as hashes and tag counters under the prefixed key families, locking while filling', async () => { const { store, prefix } = createTestingStore() - await store.set('k', 'v', { tags: ['t'] }) + await store.fetch('k', async () => { + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) + return 'v' + }, { tags: ['t'] }) await store.revalidate({ tags: ['t'] }) - await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(1) + await expect(redis.type(`${prefix}e:k`)).resolves.toBe('hash') await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) + }) + + it('defaults to no prefix', async () => { + const store = new RedisCacheStore(redis) + const key = crypto.randomUUID() + + await store.fetch(key, async () => 'v') + + await expect(redis.exists(`e:${key}`)).resolves.toBe(1) + await expect(store.fetch(key, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + + it('treats tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createTestingStore() + + await redis.hSet(`${prefix}e:k`, { output: JSON.stringify({ body: { json: 'v' } }), tags: '["t"]', tagVersions: '{}' }) + + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + + it('reloads scripts the server dropped, and rethrows other script errors', async () => { + const { store, prefix } = createTestingStore() + + await store.fetch('k', async () => 'v') + await redis.scriptFlush() + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + + await redis.hSet(`${prefix}e:broken`, { output: '{}', tags: 'not json', tagVersions: '{}' }) + await expect(store.fetch('broken', async () => 'v')).rejects.toThrow() }) it('lazily connects a closed client', async () => { @@ -71,37 +112,28 @@ describe.concurrent('redis cache store integration', { const store = new RedisCacheStore(lazyRedis, { prefix: `orpc-redis-cache-store-${crypto.randomUUID()}:` }) expect(lazyRedis.isOpen).toBe(false) - await expect(store.get('unknown')).resolves.toBeUndefined() + await expect(store.fetch('k', async () => 'v')).resolves.toMatchObject({ output: 'v' }) expect(lazyRedis.isOpen).toBe(true) await lazyRedis.destroy() }) - it('stays consistent under concurrent sets, gets, and a revalidation on a shared tag', async () => { + it('stays consistent under concurrent fetches and a revalidation on a shared tag', async () => { const { store } = createTestingStore() const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) await Promise.all([ - ...keys.map(key => store.set(key, key, { tags: ['t'] })), + ...keys.map(key => store.fetch(key, async () => key, { tags: ['t'] })), store.revalidate({ tags: ['t'] }), - ...keys.map(key => store.get(key)), ]) - // Entries snapshotted before the revalidation miss, the rest hit with their own output. - const entries = await Promise.all(keys.map(key => store.get(key))) - entries.forEach((entry, index) => { - if (entry !== undefined) { - expect(entry.output).toBe(keys[index]) - } - }) - - await store.revalidate({ tags: ['t'] }) - - await expect(Promise.all(keys.map(key => store.get(key)))).resolves.toEqual(keys.map(() => undefined)) + const entries = await Promise.all(keys.map(key => store.fetch(key, async () => key, { tags: ['t'] }))) + expect(entries.map(entry => entry.output)).toEqual(keys) }) it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { - const { store, prefix } = createTestingStore({ lockTtl: 1 }) + const { store: holderStore, prefix } = createTestingStore({ lockTtl: 1 }) + const waiterStore = new RedisCacheStore(redis, { prefix }) let release!: () => void const held = new Promise((resolve) => { release = resolve @@ -111,254 +143,24 @@ describe.concurrent('redis cache store integration', { takenOver = resolve }) - // Holds past its ttl, until the waiter has taken the lock over. - const holder = store.lock('k', () => takeover) + const holder = holderStore.fetch('k', async () => { + await takeover + return 'holder' + }) await vi.waitFor(() => expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1), { timeout: 5000 }) - const waiter = store.lock('k', async (waited) => { + const waiter = waiterStore.fetch('k', async () => { takenOver() await held - return waited + return 'waiter' }) - await holder - // The holder's release must leave the waiter's lock alone. + await expect(holder).resolves.toMatchObject({ output: 'holder' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) release() - await expect(waiter).resolves.toBe(true) + await expect(waiter).resolves.toMatchObject({ output: 'waiter' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) - }) -}) - -describe('redis cache store with a mocked client', () => { - /** - * A Map-backed stand-in for the commands the store uses, so tests can - * interleave real operations while still asserting the issued commands. - */ - function createMockedRedis() { - const data = new Map() - const queued: string[] = [] - - const incr = (key: string) => { - const next = Number(data.get(key) ?? 0) + 1 - data.set(key, String(next)) - return next - } - - const multi = { - incr: vi.fn((key: string) => { - queued.push(key) - return multi - }), - exec: vi.fn(async () => queued.splice(0).map(incr)), - } - - const redis = { - isOpen: true, - connect: vi.fn(async () => { - redis.isOpen = true - }), - get: vi.fn(async (key: string): Promise => data.get(key) ?? null), - set: vi.fn(async (key: string, value: string, options?: { condition?: 'NX' }) => { - if (options?.condition === 'NX' && data.has(key)) { - return null - } - data.set(key, value) - return 'OK' - }), - eval: vi.fn(async (_script: string, options: { keys: string[], arguments: string[] }) => { - // The release script: delete only while the key still holds the token. - if (data.get(options.keys[0]!) !== options.arguments[0]) { - return 0 - } - data.delete(options.keys[0]!) - return 1 - }), - del: vi.fn(async (key: string) => (data.delete(key) ? 1 : 0)), - incr: vi.fn(async (key: string) => incr(key)), - mGet: vi.fn(async (keys: string[]): Promise<(string | null)[]> => keys.map(key => data.get(key) ?? null)), - multi: vi.fn(() => multi), - } - - return { redis, multi } - } - - function createMockedStore() { - const { redis, multi } = createMockedRedis() - return { store: new RedisCacheStore(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:e: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 EX retention', async () => { - const { store, redis } = createMockedStore() - redis.mGet.mockResolvedValueOnce(['2']) - - await store.set('k', { a: 1 }, { tags: ['t'], ttl: 1, swr: 1 }) - - expect(redis.mGet).toHaveBeenCalledWith(['p:t:t']) - expect(redis.set).toHaveBeenCalledWith( - 'p:e:k', - expect.stringContaining('"tagVersions":{"t":2}'), - { expiration: { type: 'EX', value: 2 } }, - ) - }) - - 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:e:k', expect.any(String), undefined) - }) - - it('round-trips stored envelopes, skipping tag reads for untagged entries', async () => { - const { store, redis } = createMockedStore() - - await store.set('k', { a: 1 }) - - await expect(store.get('k')).resolves.toEqual({ output: { a: 1 }, tags: undefined, expiresAt: undefined }) - expect(redis.mGet).not.toHaveBeenCalled() - }) - - it('returns entries whose tag versions still match', async () => { - const { store } = createMockedStore() - - await store.revalidate({ tags: ['t'] }) - await store.revalidate({ tags: ['t'] }) - await store.set('k', 'v', { tags: ['t'], ttl: 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() - - await store.set('k', 'v', { tags: ['t'] }) - await store.revalidate({ tags: ['t'] }) - - await expect(store.get('k')).resolves.toBeUndefined() - expect(redis.del).toHaveBeenCalledWith('p:e:k') - }) - - it('drops an entry whose tag versions were read before a racing revalidation', async () => { - const { redis } = createMockedRedis() - const { client, read, release } = holdResult(redis, 'mGet') - const store = new RedisCacheStore(client as any, { prefix: 'p:' }) - - const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release - await read // versions are read by now - await store.revalidate({ tags: ['t'] }) - release() - await set - - expect(redis.set).toHaveBeenCalledWith('p:e:k', expect.stringContaining('"tagVersions":{"t":0}'), undefined) - await expect(store.get('k')).resolves.toBeUndefined() - expect(redis.del).toHaveBeenCalledWith('p:e:k') - }) - - it('revalidates a single tag with one INCR, and many atomically', async () => { - const { store, redis, multi } = createMockedStore() - - await store.revalidate({ tags: ['t'] }) - expect(redis.incr).toHaveBeenCalledWith('p:t:t') - - await store.revalidate({ tags: ['a', 'b'] }) - expect(multi.incr).toHaveBeenCalledWith('p:t:a') - expect(multi.incr).toHaveBeenCalledWith('p:t:b') - 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 as any) - - await store.set('k', 'v', { tags: ['t'], ttl: 1 }) - - expect(redis.set).toHaveBeenCalledWith( - 'e:k', - expect.stringContaining('"tagVersions":{"t":0}'), - { expiration: { type: 'EX', value: 1 } }, - ) - - const customStore = new RedisCacheStore(redis as any, { serializer }) - - 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: {} })) - - 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:e:\[/) - }) - - it('holds locks under the prefixed key for lockTtl and releases them with their token', async () => { - const { redis } = createMockedRedis() - const store = new RedisCacheStore(redis as any, { prefix: 'p:', lockTtl: 2 }) - - await store.lock('k', async (waited) => { - expect(waited).toBe(false) - expect(redis.set).toHaveBeenCalledWith('p:l:k', expect.any(String), { condition: 'NX', expiration: { type: 'PX', value: 2000 } }) - await expect(redis.get('p:l:k')).resolves.toEqual(expect.any(String)) - }) - - const token = redis.set.mock.calls[0]![1] - expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('DEL'), { keys: ['p:l:k'], arguments: [token] }) - await expect(redis.get('p:l:k')).resolves.toBeNull() - }) - - it('polls a held lock every 50ms with a 10 second ttl by default', async () => { - const { store, redis } = createMockedStore() - let release!: () => void - const held = new Promise((resolve) => { - release = resolve - }) - - const first = store.lock('k', () => held) - await vi.waitFor(() => expect(redis.set).toHaveBeenCalledWith('p:l:k', expect.any(String), { condition: 'NX', expiration: { type: 'PX', value: 10_000 } })) - const second = store.lock('k', async waited => waited) - - await sleep(120) - expect(redis.set.mock.calls.filter(([key]) => key === 'p:l:k').length).toBeGreaterThan(2) - - release() - await first - await expect(second).resolves.toBe(true) + await expect(holderStore.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'waiter' }) }) }) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index 5f87a030f..ca9ef71ee 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -1,10 +1,80 @@ import type { Public } from '@orpc/shared' import type { RedisClientType } from 'redis' -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' import { encodeCacheKey } from '../utils' +/** + * Reads the entry as `[output, tags, expiresAt, shouldFill]`, dropping it when + * a tag was revalidated since it was stored. A missing or stale entry also + * takes the lock, and `shouldFill` reports whether this caller got it. + */ +const FETCH_SCRIPT = ` +local fields = redis.call('HMGET', KEYS[1], 'output', 'tags', 'tagVersions', 'expiresAt') +local output, tags, versions, expiresAt = fields[1], fields[2], fields[3], fields[4] + +if output and tags then + local names = cjson.decode(tags) + local snapshot = cjson.decode(versions) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[3] .. name + end + local live = redis.call('MGET', unpack(keys)) + for i, name in ipairs(names) do + if tonumber(live[i] or 0) ~= (snapshot[name] or 0) then + redis.call('DEL', KEYS[1]) + output = false + break + end + end +end + +local stale = output and expiresAt and tonumber(expiresAt) <= tonumber(ARGV[4]) +local acquired = false +if not output or stale then + acquired = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) and true or false +end + +return { output or false, tags or false, expiresAt or false, acquired } +` + +/** + * Stores the entry with its tag versions snapshotted in the same step, then + * releases the caller's lock. + */ +const STORE_SCRIPT = ` +redis.call('DEL', KEYS[1]) +redis.call('HSET', KEYS[1], 'output', ARGV[2]) + +if ARGV[3] ~= '' then + local names = cjson.decode(ARGV[3]) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[6] .. name + end + local live = redis.call('MGET', unpack(keys)) + local snapshot = {} + for i, name in ipairs(names) do + snapshot[name] = tonumber(live[i] or 0) + end + redis.call('HSET', KEYS[1], 'tags', ARGV[3], 'tagVersions', cjson.encode(snapshot)) +end + +if ARGV[4] ~= '' then + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[4]) +end + +if ARGV[5] ~= '' then + redis.call('PEXPIRE', KEYS[1], ARGV[5]) +end + +if redis.call('GET', KEYS[2]) == ARGV[1] then + redis.call('DEL', KEYS[2]) +end +` + /** * Deletes the lock only while it still holds the caller's token, leaving one * that expired and was taken over alone. @@ -16,19 +86,6 @@ end return 0 ` -interface RedisCacheStoreEnvelope { - /** - * The cached output, encoded with the store's serializer. - */ - output: unknown - 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. @@ -55,151 +112,144 @@ export interface RedisCacheStoreOptions { /** * Cache store adapter for Redis with tag-based invalidation. Entries are - * retained for `ttl + swr` via `EX` 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. Locks are held in - * Redis with `SET NX`, so they span processes. + * hashes retained for `ttl + swr`; tag counters have no expiry since expiring + * one would resurrect stale entries. Revalidated entries are removed lazily + * on the next `fetch` of their key. Concurrent callers of one key are + * coalesced through a lock taken in the same script that reads the entry, + * so it spans processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class RedisCacheStore implements CacheStore { private readonly prefix: string + private readonly tagPrefix: string private readonly serializer: Public private readonly lockTtl: number - /** - * Key encoding has no serializer option, so one is built here rather than - * per call by {@link encodeCacheKey}. - */ private readonly keySerializer = new RPCJsonSerializer() - /** - * @param redis The Redis client to store entries in. Connected lazily when needed. - */ + private readonly scriptShas = new Map>>() + constructor( private readonly redis: RedisClientType, options: RedisCacheStoreOptions = {}, ) { this.prefix = options.prefix ?? '' + this.tagPrefix = `${this.prefix}t:` this.serializer = options.serializer ?? new RPCSerializer() this.lockTtl = options.lockTtl ?? 10 } - async get(key: unknown): Promise { + async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { await this.ensureConnection() - const entryKey = this.entryKey(key) - const raw = await this.redis.get(entryKey) - - 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), - ) + const encodedKey = encodeCacheKey(key, this.keySerializer) + const entryKey = `${this.prefix}e:${encodedKey}` + const lockKey = `${this.prefix}l:${encodedKey}` + const token = crypto.randomUUID() - if (revalidated) { - await this.redis.del(entryKey) - return undefined + while (true) { + const [output, tags, expiresAt, shouldFill] = await this.run( + FETCH_SCRIPT, + [entryKey, lockKey], + [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], + ) as [string | null, string | null, string | null, number | null] + + if (output !== null) { + const entry: CacheEntry = { + output: this.serializer.deserialize(JSON.parse(output).body), + tags: tags !== null ? JSON.parse(tags) : undefined, + expiresAt: expiresAt !== null ? Number(expiresAt) : undefined, + } + + if (shouldFill) { + const refresh = this.store(entryKey, lockKey, token, fill, options) + options.waitUntil?.(refresh) + } + + return entry } - } - - return { - output: this.serializer.deserialize(envelope.output as any), - tags: envelope.tags, - expiresAt: envelope.expiresAt, - } - } - - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const serialized = this.serializer.serialize(output) - - await this.ensureConnection() - - const tags = options?.tags - let tagVersions: Record | undefined - if (tags?.length) { - const versions = await this.redis.mGet(tags.map(tag => this.tagKey(tag))) - tagVersions = {} - tags.forEach((tag, index) => { - tagVersions![tag] = Number(versions[index] ?? 0) - }) - } - - const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + if (shouldFill) { + return this.store(entryKey, lockKey, token, fill, options) + } - const envelope: RedisCacheStoreEnvelope = { - output: serialized, - tags, - tagVersions, - expiresAt, + await sleep(50) } - - await this.redis.set( - this.entryKey(key), - stringifyJSON(envelope), - retention !== undefined ? { expiration: { type: 'EX', value: retention } } : undefined, - ) } async revalidate({ tags }: CacheRevalidateOptions): Promise { await this.ensureConnection() if (tags.length === 1) { - await this.redis.incr(this.tagKey(tags[0])) + await this.redis.incr(`${this.tagPrefix}${tags[0]}`) return } const multi = this.redis.multi() for (const tag of tags) { - multi.incr(this.tagKey(tag)) + multi.incr(`${this.tagPrefix}${tag}`) } await multi.exec() } - async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { - await this.ensureConnection() - - const lockKey = this.lockKey(key) - const token = crypto.randomUUID() - let waited = false - - while (await this.redis.set(lockKey, token, { condition: 'NX', expiration: { type: 'PX', value: this.lockTtl * 1000 } }) === null) { - waited = true - await sleep(50) // until the holder releases, or its ttl passes + private async ensureConnection(): Promise { + if (!this.redis.isOpen) { + await this.redis.connect() } + } + + private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { + let output: unknown + let serialized: string try { - return await fn(waited) + output = await fill() + serialized = stringifyJSON({ body: this.serializer.serialize(output) }) } - finally { - await this.redis.eval(RELEASE_LOCK_SCRIPT, { keys: [lockKey], arguments: [token] }) + catch (error) { + await this.run(RELEASE_LOCK_SCRIPT, [lockKey], [token]) + throw error } - } - private entryKey(key: unknown): string { - return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` - } + const tags = options.tags?.length ? options.tags : undefined + const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + + await this.run(STORE_SCRIPT, [entryKey, lockKey], [ + token, + serialized, + tags !== undefined ? stringifyJSON(tags) : '', + expiresAt !== undefined ? String(expiresAt) : '', + retention !== undefined ? String(Math.ceil(retention * 1000)) : '', + this.tagPrefix, + ]) - private tagKey(tag: string): string { - return `${this.prefix}t:${tag}` + return { output, tags, expiresAt } } - private lockKey(key: unknown): string { - return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + private async run(script: string, keys: string[], args: string[]): Promise { + try { + return await this.evalSha(script, keys, args) + } + catch (error) { + if (error instanceof Error && error.message.startsWith('NOSCRIPT')) { + this.scriptShas.delete(script) + return await this.evalSha(script, keys, args) + } + + throw error + } } - private async ensureConnection(): Promise { - if (!this.redis.isOpen) { - await this.redis.connect() + private async evalSha(script: string, keys: string[], args: string[]): Promise { + let sha = this.scriptShas.get(script) + + if (sha === undefined) { + sha = await this.redis.scriptLoad(script) + this.scriptShas.set(script, sha) } + + return await this.redis.evalSha(sha, { keys, arguments: args }) } } diff --git a/packages/cache/src/adapters/upstash.test.ts b/packages/cache/src/adapters/upstash.test.ts index d7ef7f87f..2847fa31e 100644 --- a/packages/cache/src/adapters/upstash.test.ts +++ b/packages/cache/src/adapters/upstash.test.ts @@ -2,7 +2,6 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { Redis } from '@upstash/redis' import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' -import { holdResult } from '../../tests/__shared__/utils' import { UpstashCacheStore } from './upstash' const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL @@ -24,9 +23,9 @@ describe.concurrent('upstash cache store integration', { function createTestingStore( options: ConstructorParameters[1] = {}, - client: Redis = redis, + client = redis, ) { - const prefix = `orpc-upstash-cache-store-${crypto.randomUUID()}:` + const prefix = `orpc-upstashcachestore-${crypto.randomUUID()}:` return { store: new UpstashCacheStore(client, { prefix, ...options }), prefix } } @@ -38,47 +37,80 @@ describe.concurrent('upstash cache store integration', { const deserializeSpy = vi.spyOn(serializer, 'deserialize') const { store } = createTestingStore({ serializer }) - await store.set('k', { a: 1 }) + await store.fetch('k', async () => ({ a: 1 })) - await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) expect(serializeSpy).toHaveBeenCalled() expect(deserializeSpy).toHaveBeenCalled() }) - it('evicts at ttl without swr, and serves stale within the swr window', async () => { + it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { const { store } = createTestingStore() - await store.set('no-swr', 'v', { ttl: 1 }) - await store.set('swr', 'v', { ttl: 1, swr: 10 }) + await store.fetch('no-swr', async () => 'v', { ttl: 1 }) + await store.fetch('swr', async () => 'v', { ttl: 1, swr: 10 }) await sleep(1500) - await expect(store.get('no-swr')).resolves.toBeUndefined() + await expect(store.fetch('no-swr', async () => 'refilled', { ttl: 1 })).resolves.toMatchObject({ output: 'refilled' }) - const stale = await store.get('swr') - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + const waitUntil = vi.fn() + const stale = await store.fetch('swr', async () => 'fresh', { ttl: 1, swr: 10, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + const fresh = await store.fetch('swr', async () => 'other', { ttl: 1, swr: 10 }) + expect(fresh.output).toBe('fresh') + expect(fresh.expiresAt).toBeGreaterThan(stale.expiresAt!) }) - it('stores entries and tag counters under the prefixed key families, defaulting to no prefix', async () => { + it('stores entries as hashes and tag counters under the prefixed key families, locking while filling', async () => { const { store, prefix } = createTestingStore() - await store.set('k', 'v', { tags: ['t'] }) + await store.fetch('k', async () => { + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) + return 'v' + }, { tags: ['t'] }) await store.revalidate({ tags: ['t'] }) - await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(1) + await expect(redis.type(`${prefix}e:k`)).resolves.toBe('hash') await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(1) + await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) + }) - const unprefixed = new UpstashCacheStore(redis) + it('defaults to no prefix', async () => { + const store = new UpstashCacheStore(redis) const key = crypto.randomUUID() - await unprefixed.set(key, 'v') + await store.fetch(key, async () => 'v') await expect(redis.exists(`e:${key}`)).resolves.toBe(1) - await expect(unprefixed.get(key)).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch(key, async () => 'other')).resolves.toMatchObject({ output: 'v' }) }) - it('reads envelopes when the client does not parse JSON responses', async () => { + it('treats tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createTestingStore() + + await redis.hset(`${prefix}e:k`, { output: JSON.stringify({ body: { json: 'v' } }), tags: '["t"]', tagVersions: '{}' }) + + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + + it('reloads scripts the server dropped, and rethrows other script errors', async () => { + const { store, prefix } = createTestingStore() + + await store.fetch('k', async () => 'v') + await redis.scriptFlush() + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + + await redis.hset(`${prefix}e:broken`, { output: '{}', tags: 'not json', tagVersions: '{}' }) + await expect(store.fetch('broken', async () => 'v')).rejects.toThrow() + }) + + it('reads entries when the client does not parse JSON replies', async () => { const rawRedis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN, @@ -86,41 +118,33 @@ describe.concurrent('upstash cache store integration', { }) const { store } = createTestingStore({}, rawRedis) - await store.set('k', { a: 1 }, { tags: ['t'], ttl: 60 }) + await store.fetch('k', async () => ({ a: 1 }), { tags: ['t'], ttl: 60 }) - const entry = await store.get('k') - expect(entry!.output).toEqual({ a: 1 }) - expect(entry!.tags).toEqual(['t']) - expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + const entry = await store.fetch('k', async () => 'other', { tags: ['t'], ttl: 60 }) + expect(entry.output).toEqual({ a: 1 }) + expect(entry.tags).toEqual(['t']) + expect(entry.expiresAt).toBeGreaterThan(nowInSeconds()) await store.revalidate({ tags: ['t'] }) - await expect(store.get('k')).resolves.toBeUndefined() - }) - - it('treats tags missing from the snapshot as version zero', async () => { - const { store, prefix } = createTestingStore() - - await redis.set(`${prefix}e:k`, JSON.stringify({ output: { json: 'v' }, tags: ['t'], tagVersions: {} })) - - await expect(store.get('k')).resolves.toMatchObject({ output: 'v' }) + await expect(store.fetch('k', async () => 'refilled', { tags: ['t'] })).resolves.toMatchObject({ output: 'refilled' }) }) - it('drops an entry whose tag versions were read before a racing revalidation', async () => { - const { client, read, release } = holdResult(redis, 'mget') - const { store, prefix } = createTestingStore({}, client) + it('stays consistent under concurrent fetches and a revalidation on a shared tag', async () => { + const { store } = createTestingStore() + const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) - const set = store.set('k', 'v', { tags: ['t'] }) // entry written after release - await read // versions are read by now - await store.revalidate({ tags: ['t'] }) - release() - await set + await Promise.all([ + ...keys.map(key => store.fetch(key, async () => key, { tags: ['t'] })), + store.revalidate({ tags: ['t'] }), + ]) - await expect(store.get('k')).resolves.toBeUndefined() - await expect(redis.exists(`${prefix}e:k`)).resolves.toBe(0) + const entries = await Promise.all(keys.map(key => store.fetch(key, async () => key, { tags: ['t'] }))) + expect(entries.map(entry => entry.output)).toEqual(keys) }) it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { - const { store, prefix } = createTestingStore({ lockTtl: 1 }) + const { store: holderStore, prefix } = createTestingStore({ lockTtl: 1 }) + const waiterStore = new UpstashCacheStore(redis, { prefix }) let release!: () => void const held = new Promise((resolve) => { release = resolve @@ -130,22 +154,24 @@ describe.concurrent('upstash cache store integration', { takenOver = resolve }) - // Holds past its ttl, until the waiter has taken the lock over. - const holder = store.lock('k', () => takeover) + const holder = holderStore.fetch('k', async () => { + await takeover + return 'holder' + }) await vi.waitFor(() => expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1), { timeout: 5000 }) - const waiter = store.lock('k', async (waited) => { + const waiter = waiterStore.fetch('k', async () => { takenOver() await held - return waited + return 'waiter' }) - await holder - // The holder's release must leave the waiter's lock alone. + await expect(holder).resolves.toMatchObject({ output: 'holder' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(1) release() - await expect(waiter).resolves.toBe(true) + await expect(waiter).resolves.toMatchObject({ output: 'waiter' }) await expect(redis.exists(`${prefix}l:k`)).resolves.toBe(0) + await expect(holderStore.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'waiter' }) }) }) diff --git a/packages/cache/src/adapters/upstash.ts b/packages/cache/src/adapters/upstash.ts index 00ea7417a..8f9da025f 100644 --- a/packages/cache/src/adapters/upstash.ts +++ b/packages/cache/src/adapters/upstash.ts @@ -1,10 +1,80 @@ import type { Public } from '@orpc/shared' import type { Redis } from '@upstash/redis' -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' import { encodeCacheKey } from '../utils' +/** + * Reads the entry as `[output, tags, expiresAt, shouldFill]`, dropping it when + * a tag was revalidated since it was stored. A missing or stale entry also + * takes the lock, and `shouldFill` reports whether this caller got it. + */ +const FETCH_SCRIPT = ` +local fields = redis.call('HMGET', KEYS[1], 'output', 'tags', 'tagVersions', 'expiresAt') +local output, tags, versions, expiresAt = fields[1], fields[2], fields[3], fields[4] + +if output and tags then + local names = cjson.decode(tags) + local snapshot = cjson.decode(versions) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[3] .. name + end + local live = redis.call('MGET', unpack(keys)) + for i, name in ipairs(names) do + if tonumber(live[i] or 0) ~= (snapshot[name] or 0) then + redis.call('DEL', KEYS[1]) + output = false + break + end + end +end + +local stale = output and expiresAt and tonumber(expiresAt) <= tonumber(ARGV[4]) +local acquired = false +if not output or stale then + acquired = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) and true or false +end + +return { output or false, tags or false, expiresAt or false, acquired } +` + +/** + * Stores the entry with its tag versions snapshotted in the same step, then + * releases the caller's lock. + */ +const STORE_SCRIPT = ` +redis.call('DEL', KEYS[1]) +redis.call('HSET', KEYS[1], 'output', ARGV[2]) + +if ARGV[3] ~= '' then + local names = cjson.decode(ARGV[3]) + local keys = {} + for i, name in ipairs(names) do + keys[i] = ARGV[6] .. name + end + local live = redis.call('MGET', unpack(keys)) + local snapshot = {} + for i, name in ipairs(names) do + snapshot[name] = tonumber(live[i] or 0) + end + redis.call('HSET', KEYS[1], 'tags', ARGV[3], 'tagVersions', cjson.encode(snapshot)) +end + +if ARGV[4] ~= '' then + redis.call('HSET', KEYS[1], 'expiresAt', ARGV[4]) +end + +if ARGV[5] ~= '' then + redis.call('PEXPIRE', KEYS[1], ARGV[5]) +end + +if redis.call('GET', KEYS[2]) == ARGV[1] then + redis.call('DEL', KEYS[2]) +end +` + /** * Deletes the lock only while it still holds the caller's token, leaving one * that expired and was taken over alone. @@ -16,17 +86,11 @@ end return 0 ` -interface UpstashCacheStoreEnvelope { - /** - * The cached output, encoded with the store's serializer. - */ - output: unknown - tags?: readonly string[] - /** - * Tag version counters snapshotted at set time. - */ - tagVersions?: Record - expiresAt?: number | undefined +/** + * Replies arrive parsed unless the client's `automaticDeserialization` is off. + */ +function parseReply(value: unknown): unknown { + return typeof value === 'string' ? JSON.parse(value) : value } export interface UpstashCacheStoreOptions { @@ -55,136 +119,114 @@ export interface UpstashCacheStoreOptions { /** * Cache store adapter for Upstash Redis with tag-based invalidation. Shares - * its key and envelope format with `RedisCacheStore`, so both can serve the - * same database. Entries are retained for `ttl + swr` via `EX` 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. - * Locks are held in Redis with `SET NX`, so they span processes. + * its key and entry format with `RedisCacheStore`, so both can serve the + * same database. Entries are hashes retained for `ttl + swr`; tag counters + * have no expiry since expiring one would resurrect stale entries. + * Revalidated entries are removed lazily on the next `fetch` of their key. + * Concurrent callers of one key are coalesced through a lock taken in the + * same script that reads the entry, so it spans processes. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class UpstashCacheStore implements CacheStore { private readonly prefix: string + private readonly tagPrefix: string private readonly serializer: Public private readonly lockTtl: number - /** - * Key encoding has no serializer option, so one is built here rather than - * per call by {@link encodeCacheKey}. - */ private readonly keySerializer = new RPCJsonSerializer() + private readonly fetchScript: ReturnType + private readonly storeScript: ReturnType + private readonly releaseLockScript: ReturnType + constructor( private readonly redis: Redis, options: UpstashCacheStoreOptions = {}, ) { this.prefix = options.prefix ?? '' + this.tagPrefix = `${this.prefix}t:` this.serializer = options.serializer ?? new RPCSerializer() this.lockTtl = options.lockTtl ?? 10 + this.fetchScript = redis.createScript(FETCH_SCRIPT) + this.storeScript = redis.createScript(STORE_SCRIPT) + this.releaseLockScript = redis.createScript(RELEASE_LOCK_SCRIPT) } - async get(key: unknown): Promise { - const entryKey = this.entryKey(key) - // The client parses JSON responses by default; a string means that is turned off. - const raw = await this.redis.get(entryKey) - - if (raw === null) { - return undefined - } - - const envelope = typeof raw === 'string' ? JSON.parse(raw) as UpstashCacheStoreEnvelope : raw - - if (envelope.tags?.length) { - const versions = await this.redis.mget<(string | number | null)[]>(envelope.tags.map(tag => this.tagKey(tag))) - - const revalidated = envelope.tags.some( - (tag, index) => Number(versions[index] ?? 0) !== (envelope.tagVersions?.[tag] ?? 0), - ) + async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { + const encodedKey = encodeCacheKey(key, this.keySerializer) + const entryKey = `${this.prefix}e:${encodedKey}` + const lockKey = `${this.prefix}l:${encodedKey}` + const token = crypto.randomUUID() - if (revalidated) { - await this.redis.del(entryKey) - return undefined + while (true) { + const [output, tags, expiresAt, shouldFill] = await this.fetchScript.exec( + [entryKey, lockKey], + [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], + ) as [unknown, unknown, unknown, unknown] + + if (output !== null) { + const entry: CacheEntry = { + output: this.serializer.deserialize((parseReply(output) as { body?: unknown }).body as any), + tags: tags === null ? undefined : parseReply(tags) as string[], + expiresAt: expiresAt === null ? undefined : Number(expiresAt), + } + + if (shouldFill) { + const refresh = this.store(entryKey, lockKey, token, fill, options) + options.waitUntil?.(refresh) + } + + return entry } - } - - return { - output: this.serializer.deserialize(envelope.output as any), - tags: envelope.tags, - expiresAt: envelope.expiresAt, - } - } - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { - const serialized = this.serializer.serialize(output) - - const tags = options?.tags - - let tagVersions: Record | undefined - if (tags?.length) { - const versions = await this.redis.mget<(string | number | null)[]>(tags.map(tag => this.tagKey(tag))) - tagVersions = {} - tags.forEach((tag, index) => { - tagVersions![tag] = Number(versions[index] ?? 0) - }) - } - - const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + if (shouldFill) { + return this.store(entryKey, lockKey, token, fill, options) + } - const envelope: UpstashCacheStoreEnvelope = { - output: serialized, - tags, - tagVersions, - expiresAt, + await sleep(50) } - - await this.redis.set( - this.entryKey(key), - stringifyJSON(envelope), - retention !== undefined ? { ex: retention } : undefined, - ) } async revalidate({ tags }: CacheRevalidateOptions): Promise { if (tags.length === 1) { - await this.redis.incr(this.tagKey(tags[0])) + await this.redis.incr(`${this.tagPrefix}${tags[0]}`) return } const multi = this.redis.multi() for (const tag of tags) { - multi.incr(this.tagKey(tag)) + multi.incr(`${this.tagPrefix}${tag}`) } await multi.exec() } - async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { - const lockKey = this.lockKey(key) - const token = crypto.randomUUID() - let waited = false - - while (await this.redis.set(lockKey, token, { nx: true, px: this.lockTtl * 1000 }) === null) { - waited = true - await sleep(50) // until the holder releases, or its ttl passes - } + private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { + let output: unknown + let serialized: string try { - return await fn(waited) + output = await fill() + serialized = stringifyJSON({ body: this.serializer.serialize(output) }) } - finally { - await this.redis.eval(RELEASE_LOCK_SCRIPT, [lockKey], [token]) + catch (error) { + await this.releaseLockScript.exec([lockKey], [token]) + throw error } - } - private entryKey(key: unknown): string { - return `${this.prefix}e:${encodeCacheKey(key, this.keySerializer)}` - } + const tags = options.tags?.length ? options.tags : undefined + const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - private tagKey(tag: string): string { - return `${this.prefix}t:${tag}` - } + await this.storeScript.exec([entryKey, lockKey], [ + token, + serialized, + tags !== undefined ? stringifyJSON(tags) : '', + expiresAt !== undefined ? String(expiresAt) : '', + retention !== undefined ? String(Math.ceil(retention * 1000)) : '', + this.tagPrefix, + ]) - private lockKey(key: unknown): string { - return `${this.prefix}l:${encodeCacheKey(key, this.keySerializer)}` + return { output, tags, expiresAt } } } diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index 775331dfb..22d222d34 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -18,39 +18,9 @@ describe('vercelCacheStore', () => { const store = new VercelCacheStore() const key = crypto.randomUUID() - await store.set(key, 'v') + await store.fetch(key, async () => 'v') - await expect(store.get(key)).resolves.toMatchObject({ output: 'v' }) - }) - }) - - describe('locking', () => { - it('locks per key within the process, handing on after failures', async () => { - const store = new VercelCacheStore() - const order: string[] = [] - let release!: () => void - const held = new Promise((resolve) => { - release = resolve - }) - - const first = store.lock('k', async (waited) => { - order.push(`first:${waited}`) - await held - }) - const second = store.lock('k', async (waited) => { - order.push(`second:${waited}`) - }) - await expect(store.lock('other', async waited => waited)).resolves.toBe(false) - expect(order).toEqual(['first:false']) - - release() - await Promise.all([first, second]) - expect(order).toEqual(['first:false', 'second:true']) - - await expect(store.lock('k', async () => { - throw new Error('boom') - })).rejects.toThrow('boom') - await expect(store.lock('k', async waited => waited)).resolves.toBe(false) + await expect(store.fetch(key, async () => 'other')).resolves.toMatchObject({ output: 'v' }) }) }) @@ -85,7 +55,7 @@ describe('vercelCacheStore', () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) - await store.set('k', 'v', { tags: ['t'], ttl: 1, swr: 1 }) + await store.fetch('k', async () => 'v', { tags: ['t'], ttl: 1, swr: 1 }) expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1, evictAt: 2 }), { tags: ['t'], ttl: 2 }) }) @@ -94,7 +64,7 @@ describe('vercelCacheStore', () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) - await store.set('k', 'v', { ttl: 1 }) + await store.fetch('k', async () => 'v', { ttl: 1 }) expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ expiresAt: 1, evictAt: 1 }), { ttl: 1 }) }) @@ -103,25 +73,67 @@ describe('vercelCacheStore', () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) - await store.set('k', 'v') + await store.fetch('k', async () => 'v') expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: undefined }), {}) }) - it('returns stale entries within the swr window, then evicts defensively', async () => { + it('serves stale entries within the swr window, refreshes through waitUntil, and evicts defensively', async () => { const cache = createMockedCache() const store = new VercelCacheStore({ cache }) - await store.set('k', 'v', { ttl: 1, swr: 1 }) + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) vi.setSystemTime(1200) // past ttl, within swr - await expect(store.get('k')).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + const waitUntil = vi.fn() + await expect(store.fetch('k', async () => { + throw new Error('handler down') + }, { ttl: 1, swr: 1, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1 }) + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') vi.setSystemTime(2000) // past ttl + swr, backend has not evicted yet - await expect(store.get('k')).resolves.toBeUndefined() + await expect(store.fetch('k', async () => 'refilled', { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'refilled' }) expect(cache.delete).toHaveBeenCalledWith('k') }) + it('refreshes once for concurrent stale hits, and again when the first refresh failed', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) + + vi.setSystemTime(1200) + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const waitUntil = vi.fn() + + await store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil }) + await store.fetch('k', fill, { ttl: 1, swr: 1, waitUntil }) + finish('fresh') + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(fill).toHaveBeenCalledTimes(1) + await expect(store.fetch('k', fill, { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'fresh' }) + + vi.setSystemTime(2400) // stale again + let fail!: (error: Error) => void + const failingFill = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockResolvedValue('fresher') + const waitUntilAgain = vi.fn() + + await store.fetch('k', failingFill, { ttl: 1, swr: 1, waitUntil: waitUntilAgain }) + await store.fetch('k', failingFill, { ttl: 1, swr: 1, waitUntil: waitUntilAgain }) + fail(new Error('handler down')) + + await expect(waitUntilAgain.mock.calls[0]![0]).rejects.toThrow('handler down') + await waitUntilAgain.mock.calls[1]![0] + expect(failingFill).toHaveBeenCalledTimes(2) + await expect(store.fetch('k', failingFill, { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'fresher' }) + }) + it('supports a custom serializer', async () => { const cache = createMockedCache() const serializer = new RPCSerializer() @@ -129,9 +141,9 @@ describe('vercelCacheStore', () => { const deserializeSpy = vi.spyOn(serializer, 'deserialize') const store = new VercelCacheStore({ cache, serializer }) - await store.set('k', { a: 1 }) + await store.fetch('k', async () => ({ a: 1 })) - await expect(store.get('k')).resolves.toMatchObject({ output: { a: 1 } }) + await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) expect(serializeSpy).toHaveBeenCalled() expect(deserializeSpy).toHaveBeenCalled() }) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 2d1a27775..8c3520ccf 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -1,10 +1,10 @@ import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '../types' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' import { MemoryLock, nowInSeconds } from '@orpc/shared' import { getCache } from '@vercel/functions' -import { encodeCacheKey } from '../utils' +import { encodeCacheKey, isCacheEntryStale } from '../utils' interface VercelCacheStoreEnvelope { /** @@ -36,29 +36,58 @@ export interface VercelCacheStoreOptions { * Cache store adapter for the Vercel Runtime Cache. Tags are expired * natively via `expireTag`, and entries are retained for `ttl + swr`. * Outside Vercel, the default `getCache()` falls back to an in-memory - * cache. Locks are held within the process, since the Runtime Cache has no - * atomic primitive. + * cache. Concurrent callers of one key are coalesced within the process, + * since the Runtime Cache has no atomic primitive. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class VercelCacheStore implements CacheStore { private readonly cache: RuntimeCache private readonly serializer: Public + private readonly memoryLock = new MemoryLock() /** * Key encoding has no serializer option, so one is built here rather than * per call by {@link encodeCacheKey}. */ private readonly keySerializer = new RPCJsonSerializer() - private readonly memoryLock = new MemoryLock() constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() this.serializer = options.serializer ?? new RPCSerializer() } - async get(key: unknown): Promise { + async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { const encodedKey = encodeCacheKey(key, this.keySerializer) + const entry = await this.read(encodedKey) + + if (entry === undefined) { + return this.memoryLock.run(encodedKey, async (waited) => { + const current = waited ? await this.read(encodedKey) : undefined + return current ?? this.write(encodedKey, await fill(), options) + }) + } + + if (isCacheEntryStale(entry)) { + const refresh = this.memoryLock.run(encodedKey, async (waited) => { + const current = waited ? await this.read(encodedKey) : undefined + + if (current === undefined || isCacheEntryStale(current)) { + await this.write(encodedKey, await fill(), options) + } + }) + + options.waitUntil?.(refresh) + } + + return entry + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await this.cache.expireTag([...tags]) + } + + private async read(encodedKey: string): Promise { const envelope = await this.cache.get(encodedKey) as VercelCacheStoreEnvelope | null | undefined if (envelope == null) { @@ -77,12 +106,12 @@ export class VercelCacheStore implements CacheStore { } } - async set(key: unknown, output: unknown, options?: CacheSetOptions): Promise { + private async write(encodedKey: string, output: unknown, options: CacheFetchOptions): Promise { const serialized = this.serializer.serialize(output) - const tags = options?.tags - const retention = options?.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - const expiresAt = options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined + const tags = options.tags + const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined + const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined const evictAt = retention !== undefined ? nowInSeconds() + retention : undefined const envelope: VercelCacheStoreEnvelope = { @@ -92,17 +121,11 @@ export class VercelCacheStore implements CacheStore { evictAt, } - await this.cache.set(encodeCacheKey(key, this.keySerializer), envelope, { + await this.cache.set(encodedKey, envelope, { ...(tags?.length ? { tags: [...tags] } : {}), ...(retention !== undefined ? { ttl: retention } : {}), }) - } - - async revalidate({ tags }: CacheRevalidateOptions): Promise { - await this.cache.expireTag([...tags]) - } - async lock(key: unknown, fn: (waited: boolean) => Promise): Promise { - return this.memoryLock.run(encodeCacheKey(key, this.keySerializer), fn) + return { output, tags, expiresAt } } } diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts index dc3efc887..f5e3a3637 100644 --- a/packages/cache/src/middleware.test.ts +++ b/packages/cache/src/middleware.test.ts @@ -6,22 +6,22 @@ import { MemoryCacheStore } from './adapters/memory' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' import { cache, revalidate } from './middleware' -function createStore(entry?: CacheEntry, lock?: CacheStore['lock']) { +/** + * A store that serves `entry` when given, and otherwise fills like a miss. + */ +function createStore(entry?: CacheEntry) { return { - get: vi.fn().mockResolvedValue(entry), - set: vi.fn().mockResolvedValue(undefined), + fetch: vi.fn(async (_key, fill, options) => entry ?? { + output: await fill(), + tags: options?.tags, + expiresAt: options?.ttl !== undefined ? nowInSeconds() + options.ttl : undefined, + }), revalidate: vi.fn().mockResolvedValue(undefined), - lock, } } -/** - * A lock that always reports waiting, to reach the re-read paths directly. - */ -const alwaysWaitedLock: CacheStore['lock'] = (_key, fn) => fn(true) - describe('cache', () => { - it('runs the handler and stores the output on miss', async () => { + it('fills through the store on miss and returns the output', async () => { const store = createStore() const handlerFn = vi.fn().mockReturnValue('fresh') const procedure = os @@ -34,8 +34,7 @@ describe('cache', () => { ).resolves.toBe('fresh') expect(handlerFn).toHaveBeenCalledTimes(1) - expect(store.get).toHaveBeenCalledWith('k') - expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t1', 't2'], ttl: 60, swr: 30 }) + expect(store.fetch).toHaveBeenCalledWith('k', expect.any(Function), { tags: ['t1', 't2'], ttl: 60, swr: 30, waitUntil: undefined }) }) describe('key derivation', () => { @@ -48,7 +47,7 @@ describe('cache', () => { await call(procedure, { id: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['user', 'find'] }) - const keys = store.get.mock.calls.map(([key]) => key) + const keys = store.fetch.mock.calls.map(([key]) => key) 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 @@ -68,7 +67,7 @@ describe('cache', () => { await call(material, { id: 1, page: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) await call(verbatim, undefined, { context: { 'cache/store': store } }) - const keys = store.get.mock.calls.map(([key]) => key) + const keys = store.fetch.mock.calls.map(([key]) => key) expect(keys[0]).toEqual({ id: 1 }) // the resolved material, not combined with the path expect(keys[0]).toEqual(keys[1]) // same material despite different inputs expect(keys[2]).toBe('k') @@ -88,7 +87,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) + const keys = store.fetch.mock.calls.map(([key]) => key) expect(keys[0]).not.toEqual(keys[1]) }) }) @@ -107,7 +106,6 @@ describe('cache', () => { ).resolves.toBe(expected) expect(handlerFn).not.toHaveBeenCalled() - expect(store.set).not.toHaveBeenCalled() }) it('key, tags, ttl, swr, enabled can be async functions', async () => { @@ -118,189 +116,82 @@ describe('cache', () => { const swrFn = vi.fn().mockResolvedValueOnce(30) 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') + const procedure = os.$context().input(type<{ id: number }>()).use(mw).handler(() => 'fresh') await expect( - call(procedure, '__input__', { context: { 'cache/store': store, '__context__': true }, path: ['__path__'] }), - ).resolves.toBe('ok') - - expect(store.set).toHaveBeenCalledWith('k', 'ok', { tags: ['t'], ttl: 60, swr: 30 }) + call(procedure, { id: 1 }, { context: { 'cache/store': store } }), + ).resolves.toBe('fresh') 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__', - ) + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ context: expect.any(Object) }), { id: 1 }) } + expect(store.fetch).toHaveBeenCalledWith('k', expect.any(Function), { tags: ['t'], ttl: 60, swr: 30, waitUntil: undefined }) }) - it('skips lookup and store when enabled resolves to false', async () => { - const store = createStore() + it('skips the store when enabled resolves to false', async () => { + const store = createStore({ output: 'cached', tags: [] }) const handlerFn = vi.fn().mockReturnValue('fresh') - const procedure = os.$context().use(cache({ key: 'k', enabled: () => false })).handler(handlerFn) + const procedure = os.$context().use(cache({ key: 'k', enabled: false })).handler(handlerFn) await expect( call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('fresh') - expect(store.get).not.toHaveBeenCalled() - expect(store.set).not.toHaveBeenCalled() + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.fetch).not.toHaveBeenCalled() }) - it('records misses into the handler plugin context with option tags', async () => { + it('hands cache/waitUntil to the store', async () => { const store = createStore() - const pluginContext = { caches: [], revalidations: [] } - const procedure = os - .$context() - .use(cache({ key: 'k', tags: ['t'] })) - .handler(() => 'ok') + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') - await call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - path: ['__path__'], - }) + await call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) - expect(pluginContext.caches).toEqual([ - { procedure, path: ['__path__'], tags: ['t'] }, - ]) + expect(store.fetch).toHaveBeenCalledWith('k', expect.any(Function), expect.objectContaining({ waitUntil })) }) - it('records hits into the handler plugin context with the stored entry tags', async () => { - const store = createStore({ output: 'cached', tags: ['stored'], expiresAt: nowInSeconds() + 60 }) - const pluginContext: Exclude = { caches: [], revalidations: [] } + it('records the entry into the handler plugin context with its remaining ttl', async () => { + const stale = createStore({ output: 'stale', tags: ['stored'], expiresAt: nowInSeconds() - 1 }) + const pluginContext = { caches: [], revalidations: [] } const procedure = os .$context() - .use(cache({ key: 'k', tags: ['optioned'] })) - .handler(() => 'ok') + .use(cache({ key: 'k', tags: ['t'], swr: 30 })) + .handler(() => 'fresh') + const context = { [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } - await call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - path: ['__path__'], - }) + await call(procedure, undefined, { context: { 'cache/store': stale, ...context }, path: ['__path__'] }) + await call(procedure, undefined, { context: { 'cache/store': createStore(), ...context }, path: ['__path__'] }) expect(pluginContext.caches).toEqual([ - expect.objectContaining({ procedure, path: ['__path__'], tags: ['stored'] }), + { procedure, path: ['__path__'], tags: ['stored'], ttl: 0, swr: 30 }, // the stale entry + { procedure, path: ['__path__'], tags: ['t'], ttl: undefined, swr: 30 }, // the never expiring fill ]) - expect(pluginContext.caches[0]!.ttl).toBeGreaterThan(0) // the entry's remaining freshness }) - it.each(['get', 'set'] as const)('propagates store.%s failures and records no check', async (method) => { + it('propagates store failures and records no check', async () => { const store = createStore() - store[method].mockRejectedValueOnce(new Error('store down')) + store.fetch.mockRejectedValueOnce(new Error('store down')) const pluginContext = { caches: [], revalidations: [] } - const procedure = os - .$context() - .use(cache({ key: 'k' })) - .handler(() => 'ok') + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') await expect( - call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - }), + call(procedure, undefined, { context: { 'cache/store': 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: nowInSeconds() - 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: 60, swr: 30 })) - .handler(handlerFn) - - await expect( - call(procedure, undefined, { - context: { 'cache/store': store, 'cache/waitUntil': waitUntil, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - path: ['__path__'], - }), - ).resolves.toBe('stale') - - expect(pluginContext.caches).toEqual([ - { procedure, path: ['__path__'], tags: ['t'], ttl: 0, swr: 30 }, - ]) - - expect(waitUntil).toHaveBeenCalledTimes(1) - await waitUntil.mock.calls[0]![0] - - expect(handlerFn).toHaveBeenCalledTimes(1) - expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: ['t'], ttl: 60, swr: 30 }) - }) - - it('refreshes in the background without waitUntil', async () => { - const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') - - await expect( - call(procedure, undefined, { context: { 'cache/store': store } }), - ).resolves.toBe('stale') - - await vi.waitFor(() => expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined })) - }) - - it('hands background refresh failures to waitUntil', async () => { - const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 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': store, 'cache/waitUntil': waitUntil } }), - ).resolves.toBe('stale') - - // The raw refresh is handed over, so the runtime can report the failure. - await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') - expect(store.set).not.toHaveBeenCalled() - }) - - it('leaves refresh failures unhandled without waitUntil', async ({ onTestFinished }) => { - const unhandledRejectionHandler = vi.fn() - process.on('unhandledRejection', unhandledRejectionHandler) - - onTestFinished(() => { - process.off('unhandledRejection', unhandledRejectionHandler) - }) - - const store = createStore({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - const procedure = os.$context().use(cache({ key: 'k' })).handler(() => { - throw new Error('handler down') - }) - - await expect( - call(procedure, undefined, { context: { 'cache/store': store } }), - ).resolves.toBe('stale') - - await vi.waitFor(() => expect(unhandledRejectionHandler).toHaveBeenCalledWith(new Error('handler down'), expect.any(Promise))) - expect(store.set).not.toHaveBeenCalled() - }) - }) - - describe('concurrency', () => { - it('fills once for concurrent misses when the store locks, serving the rest from the entry', async () => { + describe('with the memory store', () => { + it('runs the handler once for concurrent misses', async () => { const store = new MemoryCacheStore() let finish!: (output: string) => void const handlerFn = vi.fn(() => new Promise((resolve) => { finish = resolve })) - const pluginContext = { caches: [], revalidations: [] } - const procedure = os - .$context() - .use(cache({ key: 'k', tags: ['t'], ttl: 60 })) - .handler(handlerFn) - const run = () => call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - path: ['__path__'], - }) + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) const results = Promise.all([run(), run(), run()]) await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) @@ -308,59 +199,11 @@ describe('cache', () => { await expect(results).resolves.toEqual(['fresh', 'fresh', 'fresh']) expect(handlerFn).toHaveBeenCalledTimes(1) - // One miss, then two hits served from the entry it filled. - expect(pluginContext.caches).toHaveLength(3) - expect(pluginContext.caches).toContainEqual({ procedure, path: ['__path__'], tags: ['t'], ttl: 60, swr: undefined }) - }) - - it('fills once per caller when the store has no lock', async () => { - const store = createStore() - const handlerFn = vi.fn(() => 'fresh') - const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) - const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) - - await expect(Promise.all([run(), run()])).resolves.toEqual(['fresh', 'fresh']) - expect(handlerFn).toHaveBeenCalledTimes(2) - }) - - it('lets a waiter fill when the holder failed to', async () => { - const store = new MemoryCacheStore() - let fail!: (error: Error) => void - const handlerFn = vi.fn() - .mockImplementationOnce(() => new Promise((_, reject) => { - fail = reject - })) - .mockReturnValue('fresh') - const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) - const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) - - const first = run() - await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) - const second = run() - fail(new Error('handler down')) - - await expect(first).rejects.toThrow('handler down') - await expect(second).resolves.toBe('fresh') - expect(handlerFn).toHaveBeenCalledTimes(2) - }) - - it('fills when a waiter re-reads a still missing entry', async () => { - const store = createStore(undefined, alwaysWaitedLock) - const handlerFn = vi.fn(() => 'fresh') - const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) - - await expect( - call(procedure, undefined, { context: { 'cache/store': store } }), - ).resolves.toBe('fresh') - - expect(store.get).toHaveBeenCalledTimes(2) - expect(handlerFn).toHaveBeenCalledTimes(1) - expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) }) - it('refreshes once for concurrent stale hits when the store locks', async () => { + it('serves concurrent stale hits immediately and refreshes once through waitUntil', async () => { const store = new MemoryCacheStore() - await store.set('k', 'stale', { ttl: 0, swr: 60 }) + await store.fetch('k', async () => 'stale', { ttl: 0, swr: 60 }) let finish!: (output: string) => void const handlerFn = vi.fn(() => new Promise((resolve) => { @@ -380,42 +223,31 @@ describe('cache', () => { await expect(run()).resolves.toBe('fresh') }) - it('lets a waiter refresh when the holder failed to', async () => { - const store = new MemoryCacheStore() - await store.set('k', 'stale', { ttl: 0, swr: 60 }) - - let fail!: (error: Error) => void - const handlerFn = vi.fn() - .mockImplementationOnce(() => new Promise((_, reject) => { - fail = reject - })) - .mockReturnValue('fresh') - const waitUntil = vi.fn() - const procedure = os.$context().use(cache({ key: 'k', ttl: 60 })).handler(handlerFn) - const run = () => call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) - - await expect(Promise.all([run(), run()])).resolves.toEqual(['stale', 'stale']) - fail(new Error('handler down')) - - await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') - await waitUntil.mock.calls[1]![0] - expect(handlerFn).toHaveBeenCalledTimes(2) + it('leaves refresh failures unhandled without waitUntil', async ({ onTestFinished }) => { + // Vitest reports unhandled rejections as failures, so its listeners step aside for this test. + const listeners = process.rawListeners('unhandledRejection') as NodeJS.UnhandledRejectionListener[] + process.removeAllListeners('unhandledRejection') + const unhandledRejectionHandler = vi.fn() + process.on('unhandledRejection', unhandledRejectionHandler) - await expect(run()).resolves.toBe('fresh') - }) + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionHandler) + for (const listener of listeners) { + process.on('unhandledRejection', listener) + } + }) - it('refreshes when a waiter re-reads an evicted entry', async () => { - const store = createStore(undefined, alwaysWaitedLock) - store.get.mockResolvedValueOnce({ output: 'stale', tags: [], expiresAt: nowInSeconds() - 1 }) - const waitUntil = vi.fn() - const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + const store = new MemoryCacheStore() + await store.fetch('k', async () => 'stale', { ttl: 0, swr: 60 }) + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => { + throw new Error('handler down') + }) await expect( - call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }), + call(procedure, undefined, { context: { 'cache/store': store } }), ).resolves.toBe('stale') - await waitUntil.mock.calls[0]![0] - expect(store.set).toHaveBeenCalledWith('k', 'fresh', { tags: undefined, ttl: undefined, swr: undefined }) + await vi.waitFor(() => expect(unhandledRejectionHandler).toHaveBeenCalledWith(new Error('handler down'), expect.any(Promise))) }) }) }) @@ -424,46 +256,41 @@ describe('revalidate', () => { it('revalidates tags after the handler succeeds', async () => { const store = createStore() const pluginContext = { caches: [], revalidations: [] } + const order: string[] = [] + store.revalidate.mockImplementation(async () => { + order.push('revalidate') + }) const procedure = os .$context() - .use(revalidate({ tags: ['planets'] })) - .handler(() => 'ok') + .use(revalidate({ tags: ['t1', 't2'] })) + .handler(() => { + order.push('handler') + return 'done' + }) await expect( - call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - path: ['__path__'], - }), - ).resolves.toBe('ok') - - expect(store.revalidate).toHaveBeenCalledWith({ tags: ['planets'] }) - expect(pluginContext.revalidations).toEqual([ - { procedure, path: ['__path__'], tags: ['planets'] }, - ]) + call(procedure, undefined, { context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, path: ['__path__'] }), + ).resolves.toBe('done') + + expect(order).toEqual(['handler', 'revalidate']) + expect(store.revalidate).toHaveBeenCalledWith({ tags: ['t1', 't2'] }) + expect(pluginContext.revalidations).toEqual([{ procedure, path: ['__path__'], tags: ['t1', 't2'] }]) }) 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({ tags: tagsFn })) - .handler(() => 'ok') + const procedure = os.$context().input(type<{ id: number }>()).use(revalidate({ tags: tagsFn })).handler(() => 'done') - await call(procedure, '__input__', { context: { 'cache/store': store, '__context__': true }, path: ['__path__'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store } }) + expect(tagsFn).toHaveBeenCalledWith(expect.objectContaining({ context: expect.any(Object) }), { id: 1 }) expect(store.revalidate).toHaveBeenCalledWith({ tags: ['t'] }) - 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({ tags: ['planets'] })).handler(() => { + const procedure = os.$context().use(revalidate({ tags: ['t'] })).handler(() => { throw new Error('handler down') }) @@ -475,19 +302,16 @@ describe('revalidate', () => { }) it.each([ - ['undefined', undefined], ['null', null], - ])('skips the revalidation and recording when tags resolve to %s', async (_, tags) => { + ['undefined', undefined], + ])('skips the revalidation when tags resolve to %s', async (_, tags) => { const store = createStore() const pluginContext = { caches: [], revalidations: [] } - const procedure = os - .$context() - .use(revalidate({ tags: () => tags })) - .handler(() => 'ok') + const procedure = os.$context().use(revalidate({ tags: () => tags })).handler(() => 'done') - await call(procedure, undefined, { - context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, - }) + await expect( + call(procedure, undefined, { context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } }), + ).resolves.toBe('done') expect(store.revalidate).not.toHaveBeenCalled() expect(pluginContext.revalidations).toEqual([]) @@ -495,26 +319,23 @@ describe('revalidate', () => { }) describe('cache + revalidate combined', () => { - it('revalidates before storing on miss, and skips the revalidation on hit', async () => { - const store = createStore() + it('revalidates before filling on miss, and skips the revalidation on hit', async () => { + const store = new MemoryCacheStore() + const revalidateSpy = vi.spyOn(store, 'revalidate') + const handlerFn = vi.fn(() => 'fresh') const procedure = os .$context() .use(cache({ key: 'k', tags: ['t'] })) .use(revalidate({ tags: ['t'] })) - .handler(() => 'ok') - - await call(procedure, undefined, { context: { 'cache/store': store } }) - - expect(store.revalidate).toHaveBeenCalledTimes(1) - expect(store.set).toHaveBeenCalledTimes(1) - expect(store.revalidate.mock.invocationCallOrder[0]!).toBeLessThan(store.set.mock.invocationCallOrder[0]!) - - store.get.mockResolvedValueOnce({ output: 'cached', tags: ['t'] }) + .handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) - await expect( - call(procedure, undefined, { context: { 'cache/store': store } }), - ).resolves.toBe('cached') + // The revalidation runs inside the fill, so the stored entry snapshots the bumped tag and survives. + await expect(run()).resolves.toBe('fresh') + expect(revalidateSpy).toHaveBeenCalledTimes(1) - expect(store.revalidate).toHaveBeenCalledTimes(1) + await expect(run()).resolves.toBe('fresh') + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(revalidateSpy).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts index f6b21ff01..8a595a2bf 100644 --- a/packages/cache/src/middleware.ts +++ b/packages/cache/src/middleware.ts @@ -1,7 +1,7 @@ import type { Middleware, MiddlewareOptions } from '@orpc/server' import type { Promisable, Value } from '@orpc/shared' import type { CacheHandlerPluginContext } from './handler-plugin' -import type { CacheContext, CacheEntry, CacheStore } from './types' +import type { CacheContext } from './types' import { nowInSeconds, value } from '@orpc/shared' import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' @@ -79,90 +79,23 @@ export function cache< const store = middlewareOptions.context['cache/store'] const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] - const fill = async () => { - const result = await middlewareOptions.next() - await store.set(key, result.output, { tags, ttl, swr }) - return result - } - - const serve = (entry: CacheEntry) => { - const remainingTtl = remainingTtlOf(entry) - - if (remainingTtl === 0) { - const refresh = lock(store, key, async (waited) => { - if (waited) { - // Whoever held the lock first may have refreshed the entry already. - const current = await store.get(key) - - if (current !== undefined && remainingTtlOf(current) !== 0) { - return - } - } - - await fill() - }) - - // Whatever owns background work owns the refresh from here, failures included. - middlewareOptions.context['cache/waitUntil']?.(refresh) - } - - pluginContext?.caches.push({ - procedure: middlewareOptions.procedure, - path: middlewareOptions.path, - tags: entry.tags, - // Reflected HTTP caching headers must never outlive the store entry. - ttl: remainingTtl, - swr, - }) - - return done({ output: entry.output }) - } - - const entry = await store.get(key) - - if (entry) { - return serve(entry) - } - - return lock(store, key, async (waited) => { - if (waited) { - // Whoever held the lock first may have filled the entry already. - const entry = await store.get(key) - - if (entry) { - return serve(entry) - } - } - - const result = await fill() - - pluginContext?.caches.push({ - procedure: middlewareOptions.procedure, - path: middlewareOptions.path, - tags, - ttl, - swr, - }) - - return result + const entry = await store.fetch(key, async () => (await middlewareOptions.next()).output, { + tags, + ttl, + swr, + waitUntil: middlewareOptions.context['cache/waitUntil'], }) - } -} -/** - * Runs `fn` under the store's per-key lock when it has one, so concurrent - * callers fill an entry once; otherwise every caller fills. - */ -function lock(store: CacheStore, key: unknown, fn: (waited: boolean) => Promise): Promise { - return store.lock !== undefined ? store.lock(key, fn) : fn(false) -} + pluginContext?.caches.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + tags: entry.tags, + ttl: entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - nowInSeconds()) : undefined, + swr, + }) -/** - * The entry's remaining freshness in seconds: `0` once it is stale and - * `undefined` when it never expires. - */ -function remainingTtlOf(entry: CacheEntry): number | undefined { - return entry.expiresAt !== undefined ? Math.max(0, entry.expiresAt - nowInSeconds()) : undefined + return done({ output: entry.output }) + } } export interface RevalidateMiddlewareOptions< diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index 11e8e3b37..20e4c054d 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -16,7 +16,7 @@ export interface CacheEntry { expiresAt?: number | undefined } -export interface CacheSetOptions { +export interface CacheFetchOptions { /** * Tags associated with the entry. Revalidating any of them invalidates the entry. * @@ -39,6 +39,14 @@ export interface CacheSetOptions { * @default 0 */ swr?: number + + /** + * Takes ownership of the background refresh of a stale entry, like + * `ctx.waitUntil` on Cloudflare Workers. The promise rejects when the + * refresh fails, so this is also where such failures are handled; without + * it they surface as unhandled rejections. + */ + waitUntil?: (promise: Promise) => void } export interface CacheRevalidateOptions { @@ -49,38 +57,26 @@ export interface CacheRevalidateOptions { } /** - * Storage contract used by the cache middleware. Implementations own - * expiry and tag tracking: `set` records tags, `revalidate` invalidates - * every entry associated with them. + * Storage contract used by the cache middleware. Implementations own expiry, + * tag tracking, and how concurrent callers of one key are coalesced. * * @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. - * Keys may be any serializable value; implementations encode them stably, - * so structurally equal keys resolve the same entry. + * Resolves the entry stored under `key`, filling it through `fill` when + * there is none. Concurrent callers of one key fill once and share that + * entry. A stale entry, past `expiresAt` but within `swr`, is returned as is + * while one caller refreshes it in the background. Keys may be any + * serializable value; implementations encode them stably, so structurally + * equal keys resolve the same entry. */ - get(key: unknown): Promise - - /** - * Stores `output` under `key`, replacing any previous entry. - */ - set(key: unknown, output: unknown, options?: CacheSetOptions): Promise + fetch(key: unknown, fill: () => Promise, options?: CacheFetchOptions): Promise /** * Invalidates every entry associated with any of the given tags. */ revalidate(options: CacheRevalidateOptions): Promise - - /** - * Runs `fn` for one caller at a time per key, so a miss is filled once - * rather than once per concurrent caller. `waited` is `true` when another - * caller held the lock first, so the entry may exist by now. Stores without - * it let every caller fill. - */ - lock?(key: unknown, fn: (waited: boolean) => Promise): Promise } /** diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts index 03eb21b2b..6340c7cc7 100644 --- a/packages/cache/src/utils.ts +++ b/packages/cache/src/utils.ts @@ -1,6 +1,7 @@ import type { RPCJsonSerializer } from '@orpc/client' import type { Public } from '@orpc/shared' -import { deepSortKeys, stringifyJSON } from '@orpc/shared' +import type { CacheEntry } from './types' +import { deepSortKeys, nowInSeconds, stringifyJSON } from '@orpc/shared' /** * Encodes a cache key into a stable string: strings are used verbatim, while @@ -20,3 +21,12 @@ export function encodeCacheKey(key: unknown, serializer: Public stringifyJSON(entry)).sort()]) } + +/** + * Whether the entry is past its fresh lifetime. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function isCacheEntryStale(entry: CacheEntry): boolean { + return entry.expiresAt !== undefined && nowInSeconds() >= entry.expiresAt +} diff --git a/packages/cache/tests/__shared__/store-contract.ts b/packages/cache/tests/__shared__/store-contract.ts index f9a0cbc42..92d6ddc81 100644 --- a/packages/cache/tests/__shared__/store-contract.ts +++ b/packages/cache/tests/__shared__/store-contract.ts @@ -6,20 +6,25 @@ import { expect, it, vi } from 'vitest' * Adapter suites keep only what is specific to their backend. */ export function describeCacheStoreContract(createStore: () => CacheStore): void { - it('round-trips outputs with their tags, including undefined', async () => { + it('fills a miss once, then serves the entry with its tags', async () => { const store = createStore() + const fill = vi.fn(async () => ({ nested: [1, 2] })) - 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 expect(store.fetch('k', fill, { tags: ['t'] })).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + await expect(store.fetch('k', fill, { tags: ['t'] })).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + expect(fill).toHaveBeenCalledTimes(1) - await store.set('u', undefined) - await expect(store.get('u')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) + await store.fetch('u', async () => undefined) + await expect(store.fetch('u', async () => 'refilled')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) }) - it('misses on unknown keys', async () => { + it('fills each key separately', async () => { const store = createStore() - await expect(store.get('unknown')).resolves.toBeUndefined() + await store.fetch('a', async () => 'a') + + await expect(store.fetch('b', async () => 'b')).resolves.toMatchObject({ output: 'b' }) + await expect(store.fetch('a', async () => 'refilled')).resolves.toMatchObject({ output: 'a' }) }) it('preserves Date, Map, Set, and BigInt outputs', async () => { @@ -31,98 +36,81 @@ export function describeCacheStoreContract(createStore: () => CacheStore): void big: 123n, } - await store.set('k', output) + await store.fetch('k', async () => output) - await expect(store.get('k')).resolves.toMatchObject({ output }) + await expect(store.fetch('k', async () => 'refilled')).resolves.toMatchObject({ output }) }) it('invalidates entries by any of their tags, leaving others alone', async () => { const store = createStore() - await store.set('multi', 'v', { tags: ['a', 'b'] }) - await store.set('other', 'v', { tags: ['c'] }) + await store.fetch('multi', async () => 'v', { tags: ['a', 'b'] }) + await store.fetch('other', async () => 'v', { tags: ['c'] }) await store.revalidate({ tags: ['a'] }) - await expect(store.get('multi')).resolves.toBeUndefined() - await expect(store.get('other')).resolves.toBeDefined() + await expect(store.fetch('multi', async () => 'refilled', { tags: ['a', 'b'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.fetch('other', async () => 'refilled', { tags: ['c'] })).resolves.toMatchObject({ output: 'v' }) }) it('revalidates many tags at once', async () => { const store = createStore() - await store.set('a', 'v', { tags: ['a'] }) - await store.set('b', 'v', { tags: ['b'] }) + await store.fetch('a', async () => 'v', { tags: ['a'] }) + await store.fetch('b', async () => 'v', { tags: ['b'] }) await store.revalidate({ tags: ['a', 'b'] }) - await expect(store.get('a')).resolves.toBeUndefined() - await expect(store.get('b')).resolves.toBeUndefined() + await expect(store.fetch('a', async () => 'refilled', { tags: ['a'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.fetch('b', async () => 'refilled', { tags: ['b'] })).resolves.toMatchObject({ output: 'refilled' }) }) - it('keeps entries set after a revalidation', async () => { + it('keeps entries filled after a revalidation', async () => { const store = createStore() - await store.set('k', 'old', { tags: ['t'] }) + await store.fetch('k', async () => 'old', { tags: ['t'] }) await store.revalidate({ tags: ['t'] }) - await store.set('k', 'new', { tags: ['t'] }) + await store.fetch('k', async () => 'new', { tags: ['t'] }) - await expect(store.get('k')).resolves.toMatchObject({ output: 'new' }) + await expect(store.fetch('k', async () => 'newer', { tags: ['t'] })).resolves.toMatchObject({ output: 'new' }) }) - it('runs lock callbacks one key at a time, telling later callers they waited', async () => { + it('fills once for concurrent callers of one key', async () => { const store = createStore() - const order: string[] = [] - let release!: () => void - const held = new Promise((resolve) => { - release = resolve - }) - - const first = store.lock!('k', async (waited) => { - order.push(`first:${waited}`) - await held - return 'first' - }) - await vi.waitFor(() => expect(order).toEqual(['first:false']), { timeout: 5000 }) - - const second = store.lock!('k', async (waited) => { - order.push(`second:${waited}`) - return 'second' - }) - - // Other keys are independent of the held one. - await expect(store.lock!('other', async waited => waited)).resolves.toBe(false) - expect(order).toEqual(['first:false']) - - release() - - await expect(first).resolves.toBe('first') - await expect(second).resolves.toBe('second') - expect(order).toEqual(['first:false', 'second:true']) + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + + const fetches = Promise.all([store.fetch('k', fill), store.fetch('k', fill), store.fetch('k', fill)]) + await vi.waitFor(() => expect(fill).toHaveBeenCalledTimes(1), { timeout: 5000 }) + finish('v') + + const entries = await fetches + expect(entries.map(entry => entry.output)).toEqual(['v', 'v', 'v']) + expect(fill).toHaveBeenCalledTimes(1) }) - it('hands the lock on when the holder throws', async () => { + it('lets a waiter fill when the holder failed to', async () => { const store = createStore() let fail!: (error: Error) => void - let acquired!: () => void + let started!: () => void const holding = new Promise((resolve) => { - acquired = resolve + started = resolve }) - const first = store.lock!('k', async () => { - acquired() - await new Promise((_, reject) => { + const first = store.fetch('k', () => { + started() + return new Promise((_, reject) => { fail = reject }) }) await holding - const second = store.lock!('k', async waited => waited) - - fail(new Error('boom')) + const second = store.fetch('k', async () => 'fresh') + fail(new Error('handler down')) - await expect(first).rejects.toThrow('boom') - await expect(second).resolves.toBe(true) - await expect(store.lock!('k', async waited => waited)).resolves.toBe(false) + await expect(first).rejects.toThrow('handler down') + await expect(second).resolves.toMatchObject({ output: 'fresh' }) }) } diff --git a/packages/cache/tests/__shared__/utils.ts b/packages/cache/tests/__shared__/utils.ts deleted file mode 100644 index c82d9f0d3..000000000 --- a/packages/cache/tests/__shared__/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Wraps a client so `method` still issues its underlying call immediately but - * only resolves once `release` is called. Await `read` to know the first held - * call has completed, then run a racing operation before `release` to land it - * between that read and whatever follows it. - */ -export function holdResult(client: T, method: keyof T & string): { client: T, read: Promise, release: () => void } { - let release!: () => void - const gate = new Promise((resolve) => { - release = resolve - }) - - let settle!: () => void - const read = new Promise((resolve) => { - settle = resolve - }) - - const proxy = new Proxy(client, { - get(target, prop) { - const value = Reflect.get(target, prop) - - if (typeof value !== 'function') { - return value - } - - if (prop !== method) { - return value.bind(target) - } - - return async (...args: unknown[]) => { - const result = await value.apply(target, args) - settle() - await gate - return result - } - }, - }) - - return { client: proxy, read, release } -} diff --git a/packages/cache/tests/redis-adapters-compatibility.test.ts b/packages/cache/tests/redis-adapters-compatibility.test.ts index 4df539a00..b13ef4c1e 100644 --- a/packages/cache/tests/redis-adapters-compatibility.test.ts +++ b/packages/cache/tests/redis-adapters-compatibility.test.ts @@ -49,49 +49,47 @@ describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, ( const tag = `tag:${crypto.randomUUID()}` const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } - await source.store.set([['planet', 'find'], { b: 2, id }], output, { tags: [tag], ttl: 60 }) + await source.store.fetch([['planet', 'find'], { b: 2, id }], async () => output, { tags: [tag], ttl: 60 }) - // Structurally equal keys resolve the same entry across adapters, whatever the property order. - const entry = await target.store.get([['planet', 'find'], { id, b: 2 }]) - expect(entry!.output).toEqual(output) - expect(entry!.tags).toEqual([tag]) - expect(entry!.expiresAt).toBeGreaterThan(nowInSeconds()) + const entry = await target.store.fetch([['planet', 'find'], { id, b: 2 }], async () => 'refilled', { tags: [tag], ttl: 60 }) + expect(entry.output).toEqual(output) + expect(entry.tags).toEqual([tag]) + expect(entry.expiresAt).toBeGreaterThan(nowInSeconds()) await target.store.revalidate({ tags: [tag] }) - await expect(source.store.get([['planet', 'find'], { b: 2, id }])).resolves.toBeUndefined() + await expect(source.store.fetch([['planet', 'find'], { b: 2, id }], async () => 'refilled', { tags: [tag] })).resolves.toMatchObject({ output: 'refilled' }) }) it(`shares tag counters: ${source.name} β†’ ${target.name}`, async () => { const key = `counter:${crypto.randomUUID()}` const tag = `tag:${crypto.randomUUID()}` - await source.store.set(key, 'v1', { tags: [tag] }) + await source.store.fetch(key, async () => 'v1', { tags: [tag] }) await target.store.revalidate({ tags: [tag] }) - await expect(source.store.get(key)).resolves.toBeUndefined() - // A snapshot taken by one adapter stays valid for the other until the next revalidation. - await target.store.set(key, 'v2', { tags: [tag] }) - await expect(source.store.get(key)).resolves.toMatchObject({ output: 'v2' }) + await expect(target.store.fetch(key, async () => 'v2', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + await expect(source.store.fetch(key, async () => 'v3', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) await source.store.revalidate({ tags: [tag] }) - await expect(target.store.get(key)).resolves.toBeUndefined() + await expect(target.store.fetch(key, async () => 'v4', { tags: [tag] })).resolves.toMatchObject({ output: 'v4' }) }) it(`shares retention: ${source.name} β†’ ${target.name}`, async () => { const noSwr = `no-swr:${crypto.randomUUID()}` const swr = `swr:${crypto.randomUUID()}` - await source.store.set(noSwr, 'v', { ttl: 1 }) - await source.store.set(swr, 'v', { ttl: 1, swr: 10 }) + await source.store.fetch(noSwr, async () => 'v', { ttl: 1 }) + await source.store.fetch(swr, async () => 'v', { ttl: 1, swr: 10 }) await sleep(1500) - await expect(target.store.get(noSwr)).resolves.toBeUndefined() + await expect(target.store.fetch(noSwr, async () => 'refilled', { ttl: 1 })).resolves.toMatchObject({ output: 'refilled' }) - const stale = await target.store.get(swr) - expect(stale!.output).toBe('v') - expect(stale!.expiresAt).toBeLessThanOrEqual(nowInSeconds()) + const waitUntil = (_promise: Promise) => {} + const stale = await target.store.fetch(swr, async () => 'refilled', { ttl: 1, swr: 10, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(nowInSeconds()) }) it(`shares locks: ${source.name} β†’ ${target.name}`, async () => { @@ -105,16 +103,17 @@ describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, ( acquired = resolve }) - const holder = source.store.lock!(key, async () => { + const holder = source.store.fetch(key, async () => { acquired() await held + return 'held' }) await holding let settled = false - const waiter = target.store.lock!(key, async waited => waited).then((waited) => { + const waiter = target.store.fetch(key, async () => 'refilled').then((entry) => { settled = true - return waited + return entry }) await sleep(300) @@ -122,7 +121,7 @@ describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, ( release() await holder - await expect(waiter).resolves.toBe(true) + await expect(waiter).resolves.toMatchObject({ output: 'held' }) }) } } diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts index 241eb542c..6d602a792 100644 --- a/packages/cloudflare/src/workers-cache.test.ts +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -1,3 +1,4 @@ +import { nowInSeconds } from '@orpc/shared' import { cache } from 'cloudflare:workers' import { describe, expect, it, vi } from 'vitest' import { experimental_WorkersCacheStore } from './workers-cache' @@ -7,12 +8,18 @@ describe('experimental_WorkersCacheStore', () => { purge: vi.fn(async () => ({ success: true })), }) as any - it('always misses and stores nothing', async () => { + it('fills every time and stores nothing', async () => { const purger = createPurger() const store = new experimental_WorkersCacheStore({ cache: purger }) + const fill = vi.fn(async () => 'v') - await store.set('k', 'v', { tags: ['t'], ttl: 1000 }) - await expect(store.get('k')).resolves.toBeUndefined() + const entry = await store.fetch('k', fill, { tags: ['t'], ttl: 1000 }) + expect(entry.output).toBe('v') + expect(entry.tags).toEqual(['t']) + expect(entry.expiresAt).toBeGreaterThan(nowInSeconds()) + + await expect(store.fetch('k', fill)).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: undefined }) + expect(fill).toHaveBeenCalledTimes(2) expect(purger.purge).not.toHaveBeenCalled() }) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts index e207525d7..b264fd902 100644 --- a/packages/cloudflare/src/workers-cache.ts +++ b/packages/cloudflare/src/workers-cache.ts @@ -1,5 +1,5 @@ -import type { CacheEntry, CacheRevalidateOptions, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' -import { encodeCacheTag, toArray } from '@orpc/shared' +import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '@orpc/experimental-cache' +import { encodeCacheTag, nowInSeconds, toArray } from '@orpc/shared' import * as workers from 'cloudflare:workers' export interface experimental_WorkersCacheStoreOptions { @@ -14,9 +14,8 @@ export interface experimental_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` `headers` option), so `get` always misses and - * `set` stores nothing; `revalidate` purges the tags through Workers - * Caching. + * the `CacheHandlerPlugin` `headers` option), so `fetch` always fills and + * stores nothing; `revalidate` purges the tags through Workers Caching. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ @@ -27,17 +26,16 @@ export class experimental_WorkersCacheStore implements CacheStore { this.cache = options.cache ?? workers.cache } - async get(_key: unknown): Promise { - return undefined - } - - async set(_key: unknown, _output: unknown, _options?: CacheSetOptions): Promise { - // Storage happens at the response layer, driven by the reflected headers. + async fetch(_key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { + return { + output: await fill(), + tags: options.tags, + expiresAt: options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined, + } } async revalidate({ tags }: CacheRevalidateOptions): Promise { 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(tag => encodeCacheTag(tag)), }) diff --git a/packages/shared/src/lock.test.ts b/packages/shared/src/lock.test.ts index 2fdd716ca..f1e9d35cd 100644 --- a/packages/shared/src/lock.test.ts +++ b/packages/shared/src/lock.test.ts @@ -23,7 +23,6 @@ describe('memoryLock', () => { return 'third' }) - // Other keys are independent of the held one. await expect(lock.run('other', async waited => waited)).resolves.toBe(false) expect(order).toEqual(['first:false']) diff --git a/packages/shared/src/lock.ts b/packages/shared/src/lock.ts index 6c05b4563..5572a418c 100644 --- a/packages/shared/src/lock.ts +++ b/packages/shared/src/lock.ts @@ -14,8 +14,7 @@ export class MemoryLock { async run(key: string, fn: (waited: boolean) => Promise): Promise { const previous = this.pending.get(key) const run = () => fn(previous !== undefined) - // A failed predecessor still hands the turn on. - const current = previous === undefined ? run() : previous.then(run, run) + const current = previous?.then(run, run) ?? run() this.pending.set(key, current) From cb97a43a63b36d2bb378a7811cccd4ebc1c944df Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 8 Sep 2026 20:43:10 +0700 Subject: [PATCH 33/35] feat(cache): let the Vercel store take any Lock, defaulting to MemoryLock Stores with a native primitive keep their built-in lock. The Runtime Cache has none, so VercelCacheStore accepts a Lock from @orpc/shared and holds it within the process by default. --- apps/content/docs/helpers/cache.mdx | 11 ++++++++++- packages/cache/src/adapters/vercel.test.ts | 17 +++++++++++++++++ packages/cache/src/adapters/vercel.ts | 22 ++++++++++++++++------ packages/shared/src/lock.ts | 19 ++++++++++++++----- 4 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 2d8930598..0815f6080 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -45,7 +45,7 @@ Every duration is in seconds, matching what the underlying caches accept. 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. -Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory and Vercel stores coalesce within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. +Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory store holds it within the process. The Vercel store takes any `Lock` from `@orpc/shared` through its `lock` option and defaults to `MemoryLock`, held within the process, since the Runtime Cache has no atomic primitive; both suit stores of your own too. @@ -160,6 +160,7 @@ const store = new BunRedisCacheStore(redis, { ```ts vercel import { VercelCacheStore } from '@orpc/experimental-cache/vercel' +import { MemoryLock } from '@orpc/shared' import { getCache } from '@vercel/functions' const store = new VercelCacheStore({ @@ -177,6 +178,14 @@ const store = new VercelCacheStore({ * @default RPCSerializer */ serializer: undefined, + + /** + * Coalesces concurrent callers of one key. The Runtime Cache has no + * atomic primitive, so the default holds the lock within the process. + * + * @default new MemoryLock() + */ + lock: new MemoryLock(), }) ``` diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index 22d222d34..a060b8aa5 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -1,3 +1,4 @@ +import type { Lock } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import { RPCSerializer } from '@orpc/client' import { getCache } from '@vercel/functions' @@ -134,6 +135,22 @@ describe('vercelCacheStore', () => { await expect(store.fetch('k', failingFill, { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'fresher' }) }) + it('runs misses and stale refreshes through a custom lock', async () => { + const cache = createMockedCache() + const lock: Lock = { run: (_key, fn) => fn(false) } + const runSpy = vi.spyOn(lock, 'run') + const store = new VercelCacheStore({ cache, lock }) + + await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) + expect(runSpy).toHaveBeenCalledWith('k', expect.any(Function)) + + vi.setSystemTime(1200) + const waitUntil = vi.fn() + await store.fetch('k', async () => 'fresh', { ttl: 1, swr: 1, waitUntil }) + await waitUntil.mock.calls[0]![0] + expect(runSpy).toHaveBeenCalledTimes(2) + }) + it('supports a custom serializer', async () => { const cache = createMockedCache() const serializer = new RPCSerializer() diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 8c3520ccf..5153123d3 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -1,4 +1,4 @@ -import type { Public } from '@orpc/shared' +import type { Lock, Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' @@ -30,21 +30,30 @@ export interface VercelCacheStoreOptions { * @default RPCSerializer */ serializer?: undefined | Public + + /** + * Coalesces concurrent callers of one key, so a miss is filled once and a + * stale entry refreshed once. The Runtime Cache has no atomic primitive, + * so the default holds the lock within the process. + * + * @default new MemoryLock() + */ + lock?: Lock } /** * Cache store adapter for the Vercel Runtime Cache. Tags are expired * natively via `expireTag`, and entries are retained for `ttl + swr`. * Outside Vercel, the default `getCache()` falls back to an in-memory - * cache. Concurrent callers of one key are coalesced within the process, - * since the Runtime Cache has no atomic primitive. + * cache. Concurrent callers of one key are coalesced through the `lock` + * option, within the process by default. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class VercelCacheStore implements CacheStore { private readonly cache: RuntimeCache private readonly serializer: Public - private readonly memoryLock = new MemoryLock() + private readonly lock: Lock /** * Key encoding has no serializer option, so one is built here rather than @@ -55,6 +64,7 @@ export class VercelCacheStore implements CacheStore { constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() this.serializer = options.serializer ?? new RPCSerializer() + this.lock = options.lock ?? new MemoryLock() } async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { @@ -62,14 +72,14 @@ export class VercelCacheStore implements CacheStore { const entry = await this.read(encodedKey) if (entry === undefined) { - return this.memoryLock.run(encodedKey, async (waited) => { + return this.lock.run(encodedKey, async (waited) => { const current = waited ? await this.read(encodedKey) : undefined return current ?? this.write(encodedKey, await fill(), options) }) } if (isCacheEntryStale(entry)) { - const refresh = this.memoryLock.run(encodedKey, async (waited) => { + const refresh = this.lock.run(encodedKey, async (waited) => { const current = waited ? await this.read(encodedKey) : undefined if (current === undefined || isCacheEntryStale(current)) { diff --git a/packages/shared/src/lock.ts b/packages/shared/src/lock.ts index 5572a418c..9eb28cef4 100644 --- a/packages/shared/src/lock.ts +++ b/packages/shared/src/lock.ts @@ -1,16 +1,25 @@ /** - * A per-key mutex for one process. Callers of one key run one at a time in - * order, while other keys run independently. + * A per-key mutex. Callers of one key run one at a time, while other keys + * run independently. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class MemoryLock { - private readonly pending = new Map>() - +export interface Lock { /** * Runs `fn` once the key is free. `waited` is `true` when another caller * held it first. */ + run(key: string, fn: (waited: boolean) => Promise): Promise +} + +/** + * A {@link Lock} held within one process, in call order. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class MemoryLock implements Lock { + private readonly pending = new Map>() + async run(key: string, fn: (waited: boolean) => Promise): Promise { const previous = this.pending.get(key) const run = () => fn(previous !== undefined) From 10d3af0d08a9268e1222b48f8897eda25df13a03 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 8 Sep 2026 20:46:24 +0700 Subject: [PATCH 34/35] Revert "feat(cache): let the Vercel store take any Lock, defaulting to MemoryLock" This reverts commit cb97a43a63b36d2bb378a7811cccd4ebc1c944df. --- apps/content/docs/helpers/cache.mdx | 11 +---------- packages/cache/src/adapters/vercel.test.ts | 17 ----------------- packages/cache/src/adapters/vercel.ts | 22 ++++++---------------- packages/shared/src/lock.ts | 19 +++++-------------- 4 files changed, 12 insertions(+), 57 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 0815f6080..2d8930598 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -45,7 +45,7 @@ Every duration is in seconds, matching what the underlying caches accept. 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. -Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory store holds it within the process. The Vercel store takes any `Lock` from `@orpc/shared` through its `lock` option and defaults to `MemoryLock`, held within the process, since the Runtime Cache has no atomic primitive; both suit stores of your own too. +Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory and Vercel stores coalesce within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. @@ -160,7 +160,6 @@ const store = new BunRedisCacheStore(redis, { ```ts vercel import { VercelCacheStore } from '@orpc/experimental-cache/vercel' -import { MemoryLock } from '@orpc/shared' import { getCache } from '@vercel/functions' const store = new VercelCacheStore({ @@ -178,14 +177,6 @@ const store = new VercelCacheStore({ * @default RPCSerializer */ serializer: undefined, - - /** - * Coalesces concurrent callers of one key. The Runtime Cache has no - * atomic primitive, so the default holds the lock within the process. - * - * @default new MemoryLock() - */ - lock: new MemoryLock(), }) ``` diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts index a060b8aa5..22d222d34 100644 --- a/packages/cache/src/adapters/vercel.test.ts +++ b/packages/cache/src/adapters/vercel.test.ts @@ -1,4 +1,3 @@ -import type { Lock } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import { RPCSerializer } from '@orpc/client' import { getCache } from '@vercel/functions' @@ -135,22 +134,6 @@ describe('vercelCacheStore', () => { await expect(store.fetch('k', failingFill, { ttl: 1, swr: 1 })).resolves.toMatchObject({ output: 'fresher' }) }) - it('runs misses and stale refreshes through a custom lock', async () => { - const cache = createMockedCache() - const lock: Lock = { run: (_key, fn) => fn(false) } - const runSpy = vi.spyOn(lock, 'run') - const store = new VercelCacheStore({ cache, lock }) - - await store.fetch('k', async () => 'v', { ttl: 1, swr: 1 }) - expect(runSpy).toHaveBeenCalledWith('k', expect.any(Function)) - - vi.setSystemTime(1200) - const waitUntil = vi.fn() - await store.fetch('k', async () => 'fresh', { ttl: 1, swr: 1, waitUntil }) - await waitUntil.mock.calls[0]![0] - expect(runSpy).toHaveBeenCalledTimes(2) - }) - it('supports a custom serializer', async () => { const cache = createMockedCache() const serializer = new RPCSerializer() diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts index 5153123d3..8c3520ccf 100644 --- a/packages/cache/src/adapters/vercel.ts +++ b/packages/cache/src/adapters/vercel.ts @@ -1,4 +1,4 @@ -import type { Lock, Public } from '@orpc/shared' +import type { Public } from '@orpc/shared' import type { RuntimeCache } from '@vercel/functions' import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' @@ -30,30 +30,21 @@ export interface VercelCacheStoreOptions { * @default RPCSerializer */ serializer?: undefined | Public - - /** - * Coalesces concurrent callers of one key, so a miss is filled once and a - * stale entry refreshed once. The Runtime Cache has no atomic primitive, - * so the default holds the lock within the process. - * - * @default new MemoryLock() - */ - lock?: Lock } /** * Cache store adapter for the Vercel Runtime Cache. Tags are expired * natively via `expireTag`, and entries are retained for `ttl + swr`. * Outside Vercel, the default `getCache()` falls back to an in-memory - * cache. Concurrent callers of one key are coalesced through the `lock` - * option, within the process by default. + * cache. Concurrent callers of one key are coalesced within the process, + * since the Runtime Cache has no atomic primitive. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ export class VercelCacheStore implements CacheStore { private readonly cache: RuntimeCache private readonly serializer: Public - private readonly lock: Lock + private readonly memoryLock = new MemoryLock() /** * Key encoding has no serializer option, so one is built here rather than @@ -64,7 +55,6 @@ export class VercelCacheStore implements CacheStore { constructor(options: VercelCacheStoreOptions = {}) { this.cache = options.cache ?? getCache() this.serializer = options.serializer ?? new RPCSerializer() - this.lock = options.lock ?? new MemoryLock() } async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { @@ -72,14 +62,14 @@ export class VercelCacheStore implements CacheStore { const entry = await this.read(encodedKey) if (entry === undefined) { - return this.lock.run(encodedKey, async (waited) => { + return this.memoryLock.run(encodedKey, async (waited) => { const current = waited ? await this.read(encodedKey) : undefined return current ?? this.write(encodedKey, await fill(), options) }) } if (isCacheEntryStale(entry)) { - const refresh = this.lock.run(encodedKey, async (waited) => { + const refresh = this.memoryLock.run(encodedKey, async (waited) => { const current = waited ? await this.read(encodedKey) : undefined if (current === undefined || isCacheEntryStale(current)) { diff --git a/packages/shared/src/lock.ts b/packages/shared/src/lock.ts index 9eb28cef4..5572a418c 100644 --- a/packages/shared/src/lock.ts +++ b/packages/shared/src/lock.ts @@ -1,25 +1,16 @@ /** - * A per-key mutex. Callers of one key run one at a time, while other keys - * run independently. + * A per-key mutex for one process. Callers of one key run one at a time in + * order, while other keys run independently. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export interface Lock { +export class MemoryLock { + private readonly pending = new Map>() + /** * Runs `fn` once the key is free. `waited` is `true` when another caller * held it first. */ - run(key: string, fn: (waited: boolean) => Promise): Promise -} - -/** - * A {@link Lock} held within one process, in call order. - * - * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} - */ -export class MemoryLock implements Lock { - private readonly pending = new Map>() - async run(key: string, fn: (waited: boolean) => Promise): Promise { const previous = this.pending.get(key) const run = () => fn(previous !== undefined) From 26f97f95a9932fb8b444a3f7f28d6a5163908afe Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Tue, 8 Sep 2026 21:14:45 +0700 Subject: [PATCH 35/35] refactor(cache): share the Redis flow through BaseRedisCacheStore The scripts, the fetch loop, and the store step live once in an abstract base; the Redis, Upstash, and Bun stores only run a script through their client. Revalidation is one script too. --- apps/content/docs/helpers/cache.mdx | 2 +- packages/bun/src/redis-cache.test.ts | 25 +- packages/bun/src/redis-cache.ts | 227 ++--------------- ...cache-redis-adapters-compatibility.test.ts | 31 +-- packages/cache/src/adapters/redis.ts | 116 +++++---- packages/cache/src/adapters/upstash.ts | 228 ++---------------- 6 files changed, 122 insertions(+), 507 deletions(-) diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx index 2d8930598..7bf93261a 100644 --- a/apps/content/docs/helpers/cache.mdx +++ b/apps/content/docs/helpers/cache.mdx @@ -45,7 +45,7 @@ Every duration is in seconds, matching what the underlying caches accept. 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. -Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. The memory and Vercel stores coalesce within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. +Every adapter coalesces concurrent callers of one key. The Redis, Upstash, and Bun stores take a lock in Redis within the same script that reads the entry, so a miss costs one round trip to read and lock and one to store and release, and the lock spans processes; it is released when the fill finishes, or after `lockTtl` if it never does. They share `BaseRedisCacheStore` from `@orpc/experimental-cache/redis`, which holds the scripts and the flow, so a store for another Redis-compatible client only has to run a script. The memory and Vercel stores coalesce within the process through `MemoryLock` from `@orpc/shared`, which suits stores of your own too. diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts index 2b6902219..129f10861 100644 --- a/packages/bun/src/redis-cache.test.ts +++ b/packages/bun/src/redis-cache.test.ts @@ -1,7 +1,7 @@ import { RPCSerializer } from '@orpc/client' import { nowInSeconds, sleep } from '@orpc/shared' import { RedisClient } from 'bun' -import { afterAll, beforeAll, describe, expect, it, mock, spyOn } from 'bun:test' +import { beforeAll, describe, expect, it, mock, spyOn } from 'bun:test' import { waitFor } from '../tests/__shared__/utils' import { BunRedisCacheStore } from './redis-cache' @@ -10,14 +10,11 @@ const REDIS_URL = Bun.env.REDIS_URL describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { const redis = new RedisClient(REDIS_URL) + // Closing the client here breaks the next file's client on Bun 1.4; the process exit closes it. beforeAll(async () => { await redis.connect() }) - afterAll(() => { - redis.close() - }) - function createTestingStore(options: ConstructorParameters[1] = {}) { const prefix = `orpc-bun-redis-cache-store-${crypto.randomUUID()}:` return { store: new BunRedisCacheStore(redis, { prefix, ...options }), prefix } @@ -37,7 +34,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await store.fetch('u', async () => undefined) await expect(store.fetch('u', async () => 'refilled')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) - }) + }, { timeout: 20_000 }) it('preserves Date, Map, Set, and BigInt outputs', async () => { const { store } = createTestingStore() @@ -50,7 +47,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await store.fetch('k', async () => output) await expect(store.fetch('k', async () => 'refilled')).resolves.toMatchObject({ output }) - }) + }, { timeout: 20_000 }) it('invalidates entries by any of their tags, and keeps ones filled afterwards', async () => { const { store } = createTestingStore() @@ -63,7 +60,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await expect(store.fetch('multi', async () => 'new', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) await expect(store.fetch('other', async () => 'refilled', { tags: ['c'] })).resolves.toMatchObject({ output: 'v' }) await expect(store.fetch('multi', async () => 'newer', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) - }) + }, { timeout: 20_000 }) it('supports a custom serializer', async () => { const serializer = new RPCSerializer() @@ -76,7 +73,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) expect(serializeSpy).toHaveBeenCalled() expect(deserializeSpy).toHaveBeenCalled() - }) + }, { timeout: 20_000 }) it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { const { store } = createTestingStore() @@ -118,7 +115,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { const key = crypto.randomUUID() await unprefixed.fetch(key, async () => 'v') await expect(redis.exists(`e:${key}`)).resolves.toBe(true) - }) + }, { timeout: 20_000 }) it('treats tags missing from the snapshot as version zero', async () => { const { store, prefix } = createTestingStore() @@ -126,7 +123,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await redis.send('HSET', [`${prefix}e:k`, 'output', JSON.stringify({ body: { json: 'v' } }), 'tags', '["t"]', 'tagVersions', '{}']) await expect(store.fetch('k', async () => 'other')).resolves.toMatchObject({ output: 'v' }) - }) + }, { timeout: 20_000 }) it('reloads scripts the server dropped, and rethrows other script errors', async () => { const { store, prefix } = createTestingStore() @@ -137,7 +134,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await redis.send('HSET', [`${prefix}e:broken`, 'output', '{}', 'tags', 'not json', 'tagVersions', '{}']) await expect(store.fetch('broken', async () => 'v')).rejects.toThrow() - }) + }, { timeout: 20_000 }) it('encodes non-string keys stably', async () => { const { store } = createTestingStore() @@ -146,7 +143,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await expect(store.fetch([['planet', 'find'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'v' }) await expect(store.fetch([['planet', 'find'], { a: 1, b: 3 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) - }) + }, { timeout: 20_000 }) it('fills once for concurrent callers of one key, and lets a waiter fill when the holder failed', async () => { const { store } = createTestingStore() @@ -180,7 +177,7 @@ describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { await expect(first).rejects.toThrow('handler down') await expect(second).resolves.toMatchObject({ output: 'fresh' }) - }) + }, { timeout: 20_000 }) it('frees waiters after lockTtl and leaves a lock taken over that way alone', async () => { const { store: holderStore, prefix } = createTestingStore({ lockTtl: 1 }) diff --git a/packages/bun/src/redis-cache.ts b/packages/bun/src/redis-cache.ts index a7badf623..fc6f7e0b3 100644 --- a/packages/bun/src/redis-cache.ts +++ b/packages/bun/src/redis-cache.ts @@ -1,238 +1,45 @@ -import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '@orpc/experimental-cache' -import type { Public } from '@orpc/shared' +import type { BaseRedisCacheStoreOptions } from '@orpc/experimental-cache/redis' import type { RedisClient } from 'bun' -import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { encodeCacheKey } from '@orpc/experimental-cache' -import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' +import { BaseRedisCacheStore } from '@orpc/experimental-cache/redis' -/** - * Reads the entry as `[output, tags, expiresAt, shouldFill]`, dropping it when - * a tag was revalidated since it was stored. A missing or stale entry also - * takes the lock, and `shouldFill` reports whether this caller got it. - */ -const FETCH_SCRIPT = ` -local fields = redis.call('HMGET', KEYS[1], 'output', 'tags', 'tagVersions', 'expiresAt') -local output, tags, versions, expiresAt = fields[1], fields[2], fields[3], fields[4] - -if output and tags then - local names = cjson.decode(tags) - local snapshot = cjson.decode(versions) - local keys = {} - for i, name in ipairs(names) do - keys[i] = ARGV[3] .. name - end - local live = redis.call('MGET', unpack(keys)) - for i, name in ipairs(names) do - if tonumber(live[i] or 0) ~= (snapshot[name] or 0) then - redis.call('DEL', KEYS[1]) - output = false - break - end - end -end - -local stale = output and expiresAt and tonumber(expiresAt) <= tonumber(ARGV[4]) -local acquired = false -if not output or stale then - acquired = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) and true or false -end - -return { output or false, tags or false, expiresAt or false, acquired } -` - -/** - * Stores the entry with its tag versions snapshotted in the same step, then - * releases the caller's lock. - */ -const STORE_SCRIPT = ` -redis.call('DEL', KEYS[1]) -redis.call('HSET', KEYS[1], 'output', ARGV[2]) - -if ARGV[3] ~= '' then - local names = cjson.decode(ARGV[3]) - local keys = {} - for i, name in ipairs(names) do - keys[i] = ARGV[6] .. name - end - local live = redis.call('MGET', unpack(keys)) - local snapshot = {} - for i, name in ipairs(names) do - snapshot[name] = tonumber(live[i] or 0) - end - redis.call('HSET', KEYS[1], 'tags', ARGV[3], 'tagVersions', cjson.encode(snapshot)) -end - -if ARGV[4] ~= '' then - redis.call('HSET', KEYS[1], 'expiresAt', ARGV[4]) -end - -if ARGV[5] ~= '' then - redis.call('PEXPIRE', KEYS[1], ARGV[5]) -end - -if redis.call('GET', KEYS[2]) == ARGV[1] then - redis.call('DEL', KEYS[2]) -end -` - -/** - * Deletes the lock only while it still holds the caller's token, leaving one - * that expired and was taken over alone. - */ -const RELEASE_LOCK_SCRIPT = ` -if redis.call('GET', KEYS[1]) == ARGV[1] then - return redis.call('DEL', KEYS[1]) -end -return 0 -` - -export interface BunRedisCacheStoreOptions { - /** - * The prefix to use for Redis keys. - * - * @default undefined - */ - prefix?: string - - /** - * Serializer for cached outputs. - * - * @default RPCSerializer - */ - serializer?: undefined | Public - - /** - * How long a lock may be held, in seconds, so a crashed holder frees its - * waiters. A fill outlasting it lets the next waiter fill as well. - * - * @default 10 - */ - lockTtl?: number -} +export type BunRedisCacheStoreOptions = BaseRedisCacheStoreOptions /** - * Cache store adapter for Bun's built-in Redis client with tag-based - * invalidation. Shares its key and entry format with `RedisCacheStore`, so - * both can serve the same database. Entries are hashes retained for - * `ttl + swr`; tag counters have no expiry since expiring one would - * resurrect stale entries. Revalidated entries are removed lazily on the - * next `fetch` of their key. Concurrent callers of one key are coalesced - * through a lock taken in the same script that reads the entry, so it spans - * processes. + * Cache store adapter for Bun's built-in Redis client. Shares its key and + * entry format with `RedisCacheStore`, so both can serve the same database, + * and runs the scripts by sha, loading each once per client and again if the + * server dropped it. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class BunRedisCacheStore implements CacheStore { - private readonly prefix: string - private readonly tagPrefix: string - private readonly serializer: Public - private readonly lockTtl: number - - private readonly keySerializer = new RPCJsonSerializer() - +export class BunRedisCacheStore extends BaseRedisCacheStore { private readonly scriptShas = new Map() constructor( private readonly redis: RedisClient, options: BunRedisCacheStoreOptions = {}, ) { - this.prefix = options.prefix ?? '' - this.tagPrefix = `${this.prefix}t:` - this.serializer = options.serializer ?? new RPCSerializer() - this.lockTtl = options.lockTtl ?? 10 + super(options) } - async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { - const encodedKey = encodeCacheKey(key, this.keySerializer) - const entryKey = `${this.prefix}e:${encodedKey}` - const lockKey = `${this.prefix}l:${encodedKey}` - const token = crypto.randomUUID() - - while (true) { - const [output, tags, expiresAt, shouldFill] = await this.run( - FETCH_SCRIPT, - [entryKey, lockKey], - [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], - ) as [string | null, string | null, string | null, number | null] - - if (output !== null) { - const entry: CacheEntry = { - output: this.serializer.deserialize(JSON.parse(output).body), - tags: tags !== null ? JSON.parse(tags) : undefined, - expiresAt: expiresAt !== null ? Number(expiresAt) : undefined, - } - - if (shouldFill) { - const refresh = this.store(entryKey, lockKey, token, fill, options) - options.waitUntil?.(refresh) - } - - return entry - } - - if (shouldFill) { - return this.store(entryKey, lockKey, token, fill, options) - } + protected async run(script: string, keys: string[], args: string[], reloaded = false): Promise { + let sha = this.scriptShas.get(script) - await sleep(50) + if (sha === undefined) { + sha = await this.redis.send('SCRIPT', ['LOAD', script]) as string + this.scriptShas.set(script, sha) } - } - - async revalidate({ tags }: CacheRevalidateOptions): Promise { - await Promise.all(tags.map(tag => this.redis.incr(`${this.tagPrefix}${tag}`))) - } - - private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { - let output: unknown - let serialized: string try { - output = await fill() - serialized = stringifyJSON({ body: this.serializer.serialize(output) }) + return await this.redis.send('EVALSHA', [sha, String(keys.length), ...keys, ...args]) } catch (error) { - await this.run(RELEASE_LOCK_SCRIPT, [lockKey], [token]) - throw error - } - - const tags = options.tags?.length ? options.tags : undefined - const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - - await this.run(STORE_SCRIPT, [entryKey, lockKey], [ - token, - serialized, - tags !== undefined ? stringifyJSON(tags) : '', - expiresAt !== undefined ? String(expiresAt) : '', - retention !== undefined ? String(Math.ceil(retention * 1000)) : '', - this.tagPrefix, - ]) - - return { output, tags, expiresAt } - } - - private async run(script: string, keys: string[], args: string[]): Promise { - try { - return await this.evalSha(script, keys, args) - } - catch (error) { - if (error instanceof Error && error.message.startsWith('NOSCRIPT')) { + if (!reloaded && error instanceof Error && error.message.startsWith('NOSCRIPT')) { this.scriptShas.delete(script) - return await this.evalSha(script, keys, args) + return await this.run(script, keys, args, true) } throw error } } - - private async evalSha(script: string, keys: string[], args: string[]): Promise { - let sha = this.scriptShas.get(script) - - if (sha === undefined) { - sha = await this.redis.send('SCRIPT', ['LOAD', script]) as string - this.scriptShas.set(script, sha) - } - - return await this.redis.send('EVALSHA', [sha, String(keys.length), ...keys, ...args]) - } } diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts index 4e636e530..12ef4c5b4 100644 --- a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -15,28 +15,23 @@ const REDIS_URL = Bun.env.REDIS_URL * * All adapters must connect to the same server. */ -describe.concurrent('cache redis adapters compatibility', async () => { - const stores: Array<{ name: string, store: CacheStore }> = [] - const prefix = `redis-adapters:${crypto.randomUUID()}:` +const stores: Array<{ name: string, store: CacheStore }> = [] +const prefix = `redis-adapters:${crypto.randomUUID()}:` - if (REDIS_URL) { - const redis = createClient({ url: REDIS_URL }) +if (REDIS_URL) { + const redis = createClient({ url: REDIS_URL }) + const bunRedis = new RedisClient(REDIS_URL) - afterAll(() => { - redis.close() - }) - - stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix }) }) - - const bunRedis = new RedisClient(REDIS_URL) - - afterAll(() => { - bunRedis.close() - }) + afterAll(() => { + redis.close() + bunRedis.close() + }) - stores.push({ name: 'bun redis', store: new BunRedisCacheStore(bunRedis, { prefix }) }) - } + stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix }) }) + stores.push({ name: 'bun redis', store: new BunRedisCacheStore(bunRedis, { prefix }) }) +} +describe.concurrent('cache redis adapters compatibility', () => { describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { for (const source of stores) { for (const target of stores) { diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts index ca9ef71ee..27f66e43f 100644 --- a/packages/cache/src/adapters/redis.ts +++ b/packages/cache/src/adapters/redis.ts @@ -86,7 +86,20 @@ end return 0 ` -export interface RedisCacheStoreOptions { +const REVALIDATE_SCRIPT = ` +for _, key in ipairs(KEYS) do + redis.call('INCR', key) +end +` + +/** + * Replies arrive parsed from some clients, such as Upstash, and raw from others. + */ +function parseReply(value: unknown): unknown { + return typeof value === 'string' ? JSON.parse(value) : value +} + +export interface BaseRedisCacheStoreOptions { /** * The prefix to use for Redis keys. * @@ -111,29 +124,24 @@ export interface RedisCacheStoreOptions { } /** - * Cache store adapter for Redis with tag-based invalidation. Entries are - * hashes retained for `ttl + swr`; tag counters have no expiry since expiring - * one would resurrect stale entries. Revalidated entries are removed lazily - * on the next `fetch` of their key. Concurrent callers of one key are - * coalesced through a lock taken in the same script that reads the entry, - * so it spans processes. + * Cache store for Redis-compatible databases, driven by Lua scripts so a hit + * is one round trip and a miss two. Entries are hashes retained for + * `ttl + swr`; tag counters have no expiry since expiring one would resurrect + * stale entries. Revalidated entries are removed lazily on the next `fetch` + * of their key. Concurrent callers of one key are coalesced through a lock + * taken in the same script that reads the entry, so it spans processes. + * Subclasses only run the scripts through their client. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class RedisCacheStore implements CacheStore { +export abstract class BaseRedisCacheStore implements CacheStore { private readonly prefix: string private readonly tagPrefix: string private readonly serializer: Public private readonly lockTtl: number - private readonly keySerializer = new RPCJsonSerializer() - private readonly scriptShas = new Map>>() - - constructor( - private readonly redis: RedisClientType, - options: RedisCacheStoreOptions = {}, - ) { + constructor(options: BaseRedisCacheStoreOptions = {}) { this.prefix = options.prefix ?? '' this.tagPrefix = `${this.prefix}t:` this.serializer = options.serializer ?? new RPCSerializer() @@ -141,8 +149,6 @@ export class RedisCacheStore implements CacheStore { } async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { - await this.ensureConnection() - const encodedKey = encodeCacheKey(key, this.keySerializer) const entryKey = `${this.prefix}e:${encodedKey}` const lockKey = `${this.prefix}l:${encodedKey}` @@ -153,13 +159,13 @@ export class RedisCacheStore implements CacheStore { FETCH_SCRIPT, [entryKey, lockKey], [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], - ) as [string | null, string | null, string | null, number | null] + ) as [unknown, unknown, unknown, unknown] if (output !== null) { const entry: CacheEntry = { - output: this.serializer.deserialize(JSON.parse(output).body), - tags: tags !== null ? JSON.parse(tags) : undefined, - expiresAt: expiresAt !== null ? Number(expiresAt) : undefined, + output: this.serializer.deserialize((parseReply(output) as { body?: unknown }).body as any), + tags: tags === null ? undefined : parseReply(tags) as string[], + expiresAt: expiresAt === null ? undefined : Number(expiresAt), } if (shouldFill) { @@ -179,25 +185,13 @@ export class RedisCacheStore implements CacheStore { } async revalidate({ tags }: CacheRevalidateOptions): Promise { - await this.ensureConnection() - - if (tags.length === 1) { - await this.redis.incr(`${this.tagPrefix}${tags[0]}`) - return - } - - const multi = this.redis.multi() - for (const tag of tags) { - multi.incr(`${this.tagPrefix}${tag}`) - } - await multi.exec() + await this.run(REVALIDATE_SCRIPT, tags.map(tag => `${this.tagPrefix}${tag}`), []) } - private async ensureConnection(): Promise { - if (!this.redis.isOpen) { - await this.redis.connect() - } - } + /** + * Runs a Lua script through the client, by sha where the client allows it. + */ + protected abstract run(script: string, keys: string[], args: string[]): Promise private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { let output: unknown @@ -227,22 +221,32 @@ export class RedisCacheStore implements CacheStore { return { output, tags, expiresAt } } +} - private async run(script: string, keys: string[], args: string[]): Promise { - try { - return await this.evalSha(script, keys, args) - } - catch (error) { - if (error instanceof Error && error.message.startsWith('NOSCRIPT')) { - this.scriptShas.delete(script) - return await this.evalSha(script, keys, args) - } +export type RedisCacheStoreOptions = BaseRedisCacheStoreOptions - throw error - } +/** + * Cache store adapter for Redis. Connects the client lazily when needed and + * runs the scripts by sha, loading each once per client and again if the + * server dropped it. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class RedisCacheStore extends BaseRedisCacheStore { + private readonly scriptShas = new Map>>() + + constructor( + private readonly redis: RedisClientType, + options: RedisCacheStoreOptions = {}, + ) { + super(options) } - private async evalSha(script: string, keys: string[], args: string[]): Promise { + protected async run(script: string, keys: string[], args: string[], reloaded = false): Promise { + if (!this.redis.isOpen) { + await this.redis.connect() + } + let sha = this.scriptShas.get(script) if (sha === undefined) { @@ -250,6 +254,16 @@ export class RedisCacheStore implements CacheStore { this.scriptShas.set(script, sha) } - return await this.redis.evalSha(sha, { keys, arguments: args }) + try { + return await this.redis.evalSha(sha, { keys, arguments: args }) + } + catch (error) { + if (!reloaded && error instanceof Error && error.message.startsWith('NOSCRIPT')) { + this.scriptShas.delete(script) + return await this.run(script, keys, args, true) + } + + throw error + } } } diff --git a/packages/cache/src/adapters/upstash.ts b/packages/cache/src/adapters/upstash.ts index 8f9da025f..3292c842e 100644 --- a/packages/cache/src/adapters/upstash.ts +++ b/packages/cache/src/adapters/upstash.ts @@ -1,232 +1,34 @@ -import type { Public } from '@orpc/shared' import type { Redis } from '@upstash/redis' -import type { CacheEntry, CacheFetchOptions, CacheRevalidateOptions, CacheStore } from '../types' -import { RPCJsonSerializer, RPCSerializer } from '@orpc/client' -import { nowInSeconds, sleep, stringifyJSON } from '@orpc/shared' -import { encodeCacheKey } from '../utils' +import type { BaseRedisCacheStoreOptions } from './redis' +import { BaseRedisCacheStore } from './redis' -/** - * Reads the entry as `[output, tags, expiresAt, shouldFill]`, dropping it when - * a tag was revalidated since it was stored. A missing or stale entry also - * takes the lock, and `shouldFill` reports whether this caller got it. - */ -const FETCH_SCRIPT = ` -local fields = redis.call('HMGET', KEYS[1], 'output', 'tags', 'tagVersions', 'expiresAt') -local output, tags, versions, expiresAt = fields[1], fields[2], fields[3], fields[4] - -if output and tags then - local names = cjson.decode(tags) - local snapshot = cjson.decode(versions) - local keys = {} - for i, name in ipairs(names) do - keys[i] = ARGV[3] .. name - end - local live = redis.call('MGET', unpack(keys)) - for i, name in ipairs(names) do - if tonumber(live[i] or 0) ~= (snapshot[name] or 0) then - redis.call('DEL', KEYS[1]) - output = false - break - end - end -end - -local stale = output and expiresAt and tonumber(expiresAt) <= tonumber(ARGV[4]) -local acquired = false -if not output or stale then - acquired = redis.call('SET', KEYS[2], ARGV[1], 'NX', 'PX', ARGV[2]) and true or false -end - -return { output or false, tags or false, expiresAt or false, acquired } -` - -/** - * Stores the entry with its tag versions snapshotted in the same step, then - * releases the caller's lock. - */ -const STORE_SCRIPT = ` -redis.call('DEL', KEYS[1]) -redis.call('HSET', KEYS[1], 'output', ARGV[2]) - -if ARGV[3] ~= '' then - local names = cjson.decode(ARGV[3]) - local keys = {} - for i, name in ipairs(names) do - keys[i] = ARGV[6] .. name - end - local live = redis.call('MGET', unpack(keys)) - local snapshot = {} - for i, name in ipairs(names) do - snapshot[name] = tonumber(live[i] or 0) - end - redis.call('HSET', KEYS[1], 'tags', ARGV[3], 'tagVersions', cjson.encode(snapshot)) -end - -if ARGV[4] ~= '' then - redis.call('HSET', KEYS[1], 'expiresAt', ARGV[4]) -end - -if ARGV[5] ~= '' then - redis.call('PEXPIRE', KEYS[1], ARGV[5]) -end - -if redis.call('GET', KEYS[2]) == ARGV[1] then - redis.call('DEL', KEYS[2]) -end -` - -/** - * Deletes the lock only while it still holds the caller's token, leaving one - * that expired and was taken over alone. - */ -const RELEASE_LOCK_SCRIPT = ` -if redis.call('GET', KEYS[1]) == ARGV[1] then - return redis.call('DEL', KEYS[1]) -end -return 0 -` - -/** - * Replies arrive parsed unless the client's `automaticDeserialization` is off. - */ -function parseReply(value: unknown): unknown { - return typeof value === 'string' ? JSON.parse(value) : value -} - -export interface UpstashCacheStoreOptions { - /** - * The prefix to use for Redis keys. - * - * @default undefined - */ - prefix?: string - - /** - * Serializer for cached outputs. - * - * @default RPCSerializer - */ - serializer?: undefined | Public - - /** - * How long a lock may be held, in seconds, so a crashed holder frees its - * waiters. A fill outlasting it lets the next waiter fill as well. - * - * @default 10 - */ - lockTtl?: number -} +export type UpstashCacheStoreOptions = BaseRedisCacheStoreOptions /** - * Cache store adapter for Upstash Redis with tag-based invalidation. Shares - * its key and entry format with `RedisCacheStore`, so both can serve the - * same database. Entries are hashes retained for `ttl + swr`; tag counters - * have no expiry since expiring one would resurrect stale entries. - * Revalidated entries are removed lazily on the next `fetch` of their key. - * Concurrent callers of one key are coalesced through a lock taken in the - * same script that reads the entry, so it spans processes. + * Cache store adapter for Upstash Redis. Shares its key and entry format with + * `RedisCacheStore`, so both can serve the same database, and runs the + * scripts by sha through the client's own script cache. * * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} */ -export class UpstashCacheStore implements CacheStore { - private readonly prefix: string - private readonly tagPrefix: string - private readonly serializer: Public - private readonly lockTtl: number - - private readonly keySerializer = new RPCJsonSerializer() - - private readonly fetchScript: ReturnType - private readonly storeScript: ReturnType - private readonly releaseLockScript: ReturnType +export class UpstashCacheStore extends BaseRedisCacheStore { + private readonly scripts = new Map>() constructor( private readonly redis: Redis, options: UpstashCacheStoreOptions = {}, ) { - this.prefix = options.prefix ?? '' - this.tagPrefix = `${this.prefix}t:` - this.serializer = options.serializer ?? new RPCSerializer() - this.lockTtl = options.lockTtl ?? 10 - this.fetchScript = redis.createScript(FETCH_SCRIPT) - this.storeScript = redis.createScript(STORE_SCRIPT) - this.releaseLockScript = redis.createScript(RELEASE_LOCK_SCRIPT) + super(options) } - async fetch(key: unknown, fill: () => Promise, options: CacheFetchOptions = {}): Promise { - const encodedKey = encodeCacheKey(key, this.keySerializer) - const entryKey = `${this.prefix}e:${encodedKey}` - const lockKey = `${this.prefix}l:${encodedKey}` - const token = crypto.randomUUID() - - while (true) { - const [output, tags, expiresAt, shouldFill] = await this.fetchScript.exec( - [entryKey, lockKey], - [token, String(this.lockTtl * 1000), this.tagPrefix, String(nowInSeconds())], - ) as [unknown, unknown, unknown, unknown] - - if (output !== null) { - const entry: CacheEntry = { - output: this.serializer.deserialize((parseReply(output) as { body?: unknown }).body as any), - tags: tags === null ? undefined : parseReply(tags) as string[], - expiresAt: expiresAt === null ? undefined : Number(expiresAt), - } + protected run(script: string, keys: string[], args: string[]): Promise { + let prepared = this.scripts.get(script) - if (shouldFill) { - const refresh = this.store(entryKey, lockKey, token, fill, options) - options.waitUntil?.(refresh) - } - - return entry - } - - if (shouldFill) { - return this.store(entryKey, lockKey, token, fill, options) - } - - await sleep(50) + if (prepared === undefined) { + prepared = this.redis.createScript(script) + this.scripts.set(script, prepared) } - } - - async revalidate({ tags }: CacheRevalidateOptions): Promise { - if (tags.length === 1) { - await this.redis.incr(`${this.tagPrefix}${tags[0]}`) - return - } - - const multi = this.redis.multi() - for (const tag of tags) { - multi.incr(`${this.tagPrefix}${tag}`) - } - await multi.exec() - } - - private async store(entryKey: string, lockKey: string, token: string, fill: () => Promise, options: CacheFetchOptions): Promise { - let output: unknown - let serialized: string - - try { - output = await fill() - serialized = stringifyJSON({ body: this.serializer.serialize(output) }) - } - catch (error) { - await this.releaseLockScript.exec([lockKey], [token]) - throw error - } - - const tags = options.tags?.length ? options.tags : undefined - const expiresAt = options.ttl !== undefined ? nowInSeconds() + options.ttl : undefined - const retention = options.ttl !== undefined ? options.ttl + (options.swr ?? 0) : undefined - - await this.storeScript.exec([entryKey, lockKey], [ - token, - serialized, - tags !== undefined ? stringifyJSON(tags) : '', - expiresAt !== undefined ? String(expiresAt) : '', - retention !== undefined ? String(Math.ceil(retention * 1000)) : '', - this.tagPrefix, - ]) - return { output, tags, expiresAt } + return prepared.exec(keys, args) } }