diff --git a/README.md b/README.md index 3dac971f8..38898635b 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index 16baa4665..9f5d452eb 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/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) | @@ -51,7 +52,7 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | | [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | | [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher and Rate Limit. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | -| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Cloudflare Durable Object and Rate Limit adapters. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | +| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | ## Observability diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx new file mode 100644 index 000000000..812de1f9e --- /dev/null +++ b/apps/content/docs/helpers/cache.mdx @@ -0,0 +1,290 @@ +--- +title: "Cache Helpers" +description: "Cache oRPC procedure output with tag-based revalidation, stale-while-revalidate, storage adapters, and a handler plugin that reflects cache tags in HTTP headers." +sidebar: + label: "Cache" +--- + +## Installation + +```package-install +npm install @orpc/experimental-cache@beta +``` + +## Basic Usage + +The core concept is the `CacheStore` interface, which defines a standard way to store, look up, and invalidate cached output by tags. You can create your own custom store or use one of the provided adapters. A router shares a single store, provided through the request context as defined by the `CacheContext` interface. + +```ts twoslash +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' +// ---cut--- +const store = new MemoryCacheStore() + +await store.set('planet:1', { id: 1, name: 'Earth' }, { + tags: ['planets', 'planet:1'], + ttl: 60_000, +}) + +const entry = await store.get('planet:1') + +await store.revalidateTag('planets') // now `get` misses +``` + +An entry stays fresh for `ttl` milliseconds and is retained for an extra `swr` window afterward, during which `get` still returns it with a past `expiresAt` so callers can serve it stale while refreshing. Revalidating a tag invalidates every entry associated with it, fresh or stale. + +## Adapters + +| Name | Adapter for | +| ------------------- | ------------------------------------------------------------------------------------------ | +| `MemoryCacheStore` | In-memory storage | +| `RedisCacheStore` | [Redis](https://github.com/redis/redis) | +| `VercelCacheStore` | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | +| `experimental_KVCacheStore` | [Cloudflare Workers KV](https://developers.cloudflare.com/kv/) | +| `experimental_WorkersCacheStore` | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | + +Keys may be any serializable value. Strings are used verbatim, while anything else is encoded with `encodeCacheKey`: serialized first, so complex values like Date, Map, or Set become plain JSON, then canonicalized, so structurally equal keys resolve the same entry regardless of property order. Reuse it when implementing your own store. + + + +```ts memory +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' + +const store = new MemoryCacheStore({ + /** + * Serializer used to encode non-string keys. + * + * @default RPCJsonSerializer + */ + serializer: undefined, +}) +``` + +```ts redis +import { RedisCacheStore } from '@orpc/experimental-cache/redis' +import { createClient } from 'redis' + +const client = createClient({ url: 'redis://localhost:6379' }) + +// RedisCacheStore lazily connects to Redis when needed. +// You can still call `client.connect()` manually, but it is optional. +await client.connect() + +const store = new RedisCacheStore({ + /** + * The Redis client to store entries in. Connected lazily when needed. + */ + redis: client, + + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer + */ + serializer: undefined, +}) +``` + +```ts vercel +import { VercelCacheStore } from '@orpc/experimental-cache/vercel' +import { getCache } from '@vercel/functions' + +const store = new VercelCacheStore({ + /** + * The Vercel Runtime Cache to use. Outside Vercel, + * it falls back to an in-memory cache. + * + * @default getCache() + */ + cache: getCache(), + + /** + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer + */ + serializer: undefined, +}) +``` + +```ts cloudflare-kv +import { experimental_KVCacheStore as KVCacheStore } from '@orpc/cloudflare' + +export default { + async fetch(request, env) { + // KV is eventually consistent: writes and revalidations may take + // 60 seconds or more to be visible in other locations. + const store = new KVCacheStore({ + /** + * The KV namespace to store entries in. + */ + kv: env.CACHE_KV, + + /** + * The prefix to use for KV keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for cached outputs. Outputs containing Blob or File + * values are ignored and never stored. + * + * @default RPCSerializer + */ + serializer: undefined, + }) + }, +} +``` + +```ts cloudflare-workers-caching +import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare' + +export default { + async fetch(request, env, ctx) { + // Workers Caching caches whole responses in front of the Worker via the + // `cache-control` and `cache-tag` plugin headers; this store only purges + // tags on revalidation. Requires `"cache": { "enabled": true }` in your + // wrangler configuration. Purges are scoped to the calling entrypoint, + // tags match case-insensitively, and purge calls always use the Free + // tier rate limits regardless of your plan. + const store = new WorkersCacheStore({ cache: ctx.cache }) + }, +} +``` + + + +## Cache Middleware + +The `cache` helper creates middleware that caches the output of [procedures](/docs/procedure). On a hit it returns the cached output without executing the handler, and on a miss it executes the handler and stores the result. The `key`, `tags`, `ttl`, `swr`, and `enabled` options accept static values or functions of the middleware options and input. + +The `key` is optional: by default it is derived from the procedure path and input. When provided, strings are used verbatim, while any other serializable value is combined with the procedure path and encoded into a key. + +```ts +import { cache, CacheContext } from '@orpc/experimental-cache' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' + +const findPlanet = os + .$context() + .input(z.object({ id: z.number() })) + .use( + cache({ + key: (_, input) => `planet:${input.id}`, + tags: (_, input) => ['planets', `planet:${input.id}`], + ttl: 60_000, // Optional fresh lifetime, default is no expiry + swr: 300_000, // Optional stale-while-revalidate window, default is 0 + }), + ) + .handler(({ input }) => { + return { id: input.id, name: `Planet ${input.id}` } + }) + +const result = await call( + findPlanet, + { id: 1 }, + { context: { cache: new MemoryCacheStore() } }, +) +``` + +:::info +Entries are stored only when the handler succeeds. Streaming outputs, such as [AsyncIteratorObject](/docs/async-iterator-object) and readable streams, are never cached. +::: + +:::warning +A cached entry is shared by everyone using the same key. If output depends on the requester, include the distinguishing part in `key`, or resolve `enabled` to `false` to bypass caching for that request. +::: + +### Stale While Revalidate + +When an entry is past `ttl` but within the `swr` window, the middleware returns the stale output immediately and re-executes the procedure in the background to refresh the entry. Concurrent stale hits may each trigger a refresh; the cache never serves anything older than `ttl + swr`. + +On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, provide `waitUntil` through the context so background refreshes can finish: + +```ts +export default { + async fetch(request, env, ctx) { + const { response } = await handler.handle(request, { + context: { + cache: store, + waitUntil: ctx.waitUntil.bind(ctx), + }, + }) + + return response ?? new Response('Not Found', { status: 404 }) + }, +} +``` + +## Revalidate Middleware + +The `revalidate` helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. It accepts one tag, a non-empty list of tags, or a function of the middleware options and input. If the procedure throws, the revalidation is skipped. + +```ts +import { revalidate } from '@orpc/experimental-cache' + +const updatePlanet = os + .$context() + .input(z.object({ id: z.number(), name: z.string() })) + .use( + revalidate((_, input) => ['planets', `planet:${input.id}`]), + ) + .handler(({ input }) => { + return input + }) +``` + +## Handler Plugin + +The `CacheHandlerPlugin` reflects the cache activity of [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware) into response headers. It does nothing by default; only the headers you list are set: + +- `orpc-cache-tag` carries the tags the response depends on. +- `orpc-cache-tag-invalidation` carries the tags revalidated by the request, useful for invalidating tagged data in client caches. +- `cache-control` and `cache-tag` are the standard HTTP counterparts for response caches in front, such as CDNs or Cloudflare Workers Caching. They are only set on GET and HEAD responses and never override existing headers. + +Tags are joined with commas. Only `%`, `,`, uppercase letters, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Uppercase letters are encoded because caches like Cloudflare Workers Caching match tags case-insensitively; the encoded form stays unambiguous under case folding. Use `decodeCacheTagHeader` to parse a header back into tags. + +```ts +import { CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, CacheHandlerPlugin } from '@orpc/experimental-cache' + +const handler = new RPCHandler(router, { + plugins: [ + new CacheHandlerPlugin({ + headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER], + }), + ], +}) +``` + +:::info[Response Caches in Front] +With `cache-control` and `cache-tag` configured, a response cache in front serves cached responses without invoking your server at all. Pair it with a purge-capable store, such as `experimental_WorkersCacheStore`, so revalidations also purge the front cache. Since standard HTTP caches only store GET and HEAD responses, this mainly benefits [OpenAPIHandler](/docs/openapi/handler) routes; RPC requests use POST. +::: + +:::info +When a procedure calls other procedures, only the first cache check and the first revalidation of the procedure the client called are reflected. Nested procedures never leak their tags into the response. Headers appear only on successful responses. +::: + +:::tip[Cross-Origin Clients] +The headers use oRPC-specific names on purpose: CDN-facing conventions like `Cache-Tag` can be consumed and stripped by intermediaries before reaching the browser, while these always arrive intact for client-side revalidation. For cross-origin browser clients, list them in [CORSPlugin](/docs/plugins/cors)'s `exposeHeaders` so client code can read them: + +```ts +new CORSPlugin({ + exposeHeaders: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER], +}) +``` + +::: + +:::info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. +::: diff --git a/apps/content/package.json b/apps/content/package.json index 49e65ba12..1b7c8a1a9 100644 --- a/apps/content/package.json +++ b/apps/content/package.json @@ -17,6 +17,7 @@ "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/opentelemetry": "workspace:*", "@orpc/pino": "workspace:*", 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 82feadaf9..aad08e1e6 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/experimental-effect": "workspace:*", "@orpc/experimental-msw": "workspace:*", "@orpc/hibernation": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index a3ec901a4..633334cc6 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index f8fe5aa39..264b187bd 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/bun/README.md b/packages/bun/README.md index 97a5dcbae..703e5a5fe 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -56,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 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 new file mode 100644 index 000000000..3419326d2 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,160 @@ +

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://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. + +**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/). + +**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, 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. + +**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. + +**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. + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going through [GitHub Sponsors](https://github.com/sponsors/dinwwwh) or [Open Collective](https://opencollective.com/middleapi). Every bit helps! πŸš€ + + + + + + + + +
ScreenshotOne.comScreenshotOne.com
The screenshot API for developers
MisskeyHQMisskeyHQ
Decentralized microblogging SNS born on Earth
+ +### Organization 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
Orestis Ioannou
Orestis Ioannou
+ +### 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](https://htmlpreview.github.io/?https://github.com/middleapi/static/blob/main/sponsors.svg) 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..2e6ec3eab --- /dev/null +++ b/packages/cache/package.json @@ -0,0 +1,91 @@ +{ + "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", + "license": "MIT", + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" + ], + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/cache" + }, + "keywords": [ + "orpc", + "cache", + "caching", + "revalidation", + "stale-while-revalidate", + "redis", + "vercel", + "middleware", + "api", + "typescript" + ], + "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.2" + }, + "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..3cf7b8b07 --- /dev/null +++ b/packages/cache/src/adapters/memory.test.ts @@ -0,0 +1,135 @@ +import { RPCJsonSerializer } from '@orpc/client' +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('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('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() + + 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 () => { + 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..bfb5740ed --- /dev/null +++ b/packages/cache/src/adapters/memory.ts @@ -0,0 +1,91 @@ +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[] + /** + * 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() + private readonly serializer: Public | undefined + + constructor(options: MemoryCacheStoreOptions = {}) { + this.serializer = options.serializer + } + + async get(key: unknown): Promise { + 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(encodedKey) + return undefined + } + + const revalidated = entry.tags.some( + (tag, index) => (this.tagVersions.get(tag) ?? 0) !== entry.tagVersions[index], + ) + + if (revalidated) { + this.entries.delete(encodedKey) + return undefined + } + + return { + output: entry.output, + tags: entry.tags, + expiresAt: entry.expiresAt, + } + } + + 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(encodeCacheKey(key, this.serializer), { + 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/redis.test.ts b/packages/cache/src/adapters/redis.test.ts new file mode 100644 index 000000000..5ec1405e1 --- /dev/null +++ b/packages/cache/src/adapters/redis.test.ts @@ -0,0 +1,319 @@ +import { RPCSerializer } from '@orpc/client' +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: Partial[0]> = {}, + ) { + 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, 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('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: 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({ redis: 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() + }) +}) + +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 new file mode 100644 index 000000000..1dee7da12 --- /dev/null +++ b/packages/cache/src/adapters/redis.ts @@ -0,0 +1,165 @@ +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 { encodeCacheKey } from '../utils' + +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 Redis client to store entries in. Connected lazily when needed. + */ + redis: RedisClientType + + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix?: string + + /** + * Serializer for cached outputs. + * + * @default RPCSerializer + */ + 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. 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: Public + + constructor(options: RedisCacheStoreOptions) { + this.redis = options.redis + this.prefix = options.prefix ?? '' + this.serializer = options.serializer ?? new RPCSerializer() + } + + async get(key: unknown): 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), + ) + + 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) + + // 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 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: unknown): string { + return `${this.prefix}entry:${encodeCacheKey(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..91131631c --- /dev/null +++ b/packages/cache/src/adapters/vercel.test.ts @@ -0,0 +1,177 @@ +import type { RuntimeCache } from '@vercel/functions' +import { RPCSerializer } from '@orpc/client' +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, 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() + }) + + 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', () => { + 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('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 }) + + 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 = 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(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts new file mode 100644 index 000000000..346eefd1f --- /dev/null +++ b/packages/cache/src/adapters/vercel.ts @@ -0,0 +1,108 @@ +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 { getCache } from '@vercel/functions' +import { encodeCacheKey } from '../utils' + +interface VercelCacheStoreEnvelope { + /** + * The cached output, encoded with the store's serializer. + */ + output: unknown + 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 RPCSerializer + */ + 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. 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: Public + + constructor(options: VercelCacheStoreOptions = {}) { + this.cache = options.cache ?? getCache() + this.serializer = options.serializer ?? new RPCSerializer() + } + + async get(key: unknown): Promise { + 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(encodedKey) + 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) + + // 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: serialized, + tags, + expiresAt, + evictAt, + } + + await this.cache.set(encodeCacheKey(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..7e6dd5f5f --- /dev/null +++ b/packages/cache/src/handler-plugin.test.ts @@ -0,0 +1,274 @@ +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({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), + ], + }) + + afterEach(() => { + 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( + { 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({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] })], + }) + + 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 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'] }), + ], + }) + + 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(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') + }) + + 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')).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')).toBe(null) + expect(response!.headers.get('cache-control')).toBe(null) + }) +}) + +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'] + + 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 new file mode 100644 index 000000000..e6eedb872 --- /dev/null +++ b/packages/cache/src/handler-plugin.ts @@ -0,0 +1,175 @@ +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: unknown, 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 `%`, `,`, 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 + | 'cache-control' + | 'cache-tag' + +export interface CacheHandlerPluginOptions { + /** + * 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 [] + */ + headers?: readonly CacheHandlerPluginHeader[] +} + +/** + * 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 headers: Set + + constructor(options: CacheHandlerPluginOptions = {}) { + 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: [] } + + 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 isHttpCacheable = rootCache !== undefined && (method === 'GET' || method === 'HEAD') + + const headers: StandardHeaders = { ...response.headers } + let changed = false + + if (this.headers.has(CACHE_TAG_HEADER) && rootCache?.tags.length) { + headers[CACHE_TAG_HEADER] = encodeCacheTagHeader(rootCache.tags) + changed = true + } + + 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 (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 changed ? { ...response, headers } : response + } + + 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..795670487 --- /dev/null +++ b/packages/cache/src/index.ts @@ -0,0 +1,4 @@ +export * from './handler-plugin' +export * from './middleware' +export * from './types' +export * from './utils' 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..127629085 --- /dev/null +++ b/packages/cache/src/middleware.test.ts @@ -0,0 +1,422 @@ +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[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 () => { + 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]).toEqual(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.toEqual(keys[1]) + }) + }) + + 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..e52aa0577 --- /dev/null +++ b/packages/cache/src/middleware.ts @@ -0,0 +1,183 @@ +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 { isAsyncIteratorObject, 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 by the store. + * + * @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 : [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 +} diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts new file mode 100644 index 000000000..dc096eef3 --- /dev/null +++ b/packages/cache/src/types.ts @@ -0,0 +1,98 @@ +/** + * 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. + * 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 + + /** + * 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/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..45944d6ca --- /dev/null +++ b/packages/cache/src/utils.ts @@ -0,0 +1,22 @@ +import type { Public } from '@orpc/shared' +import { RPCJsonSerializer } from '@orpc/client' +import { deepSortKeys, stringifyJSON } from '@orpc/shared' + +/** + * 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 = new RPCJsonSerializer()): 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/cache/tests/e2e.test.ts b/packages/cache/tests/e2e.test.ts new file mode 100644 index 000000000..d42fb0281 --- /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, CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER, 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({ headers: [CACHE_TAG_HEADER, CACHE_TAG_INVALIDATION_HEADER] }), + ], + }) + + 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 3dac971f8..38898635b 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index d4c10d1d2..758ae3496 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 3abd6d417..78125ca7e 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: Durable Object pub/sub and Workers rate limiting adapters", + "description": "oRPC adapters for Cloudflare Workers", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", @@ -21,6 +21,8 @@ "durable-objects", "ratelimit", "pubsub", + "cache", + "kv", "typescript" ], "sideEffects": false, @@ -50,6 +52,7 @@ }, "dependencies": { "@orpc/client": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", "@orpc/shared": "workspace:*", diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index c97259472..128ce5df9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -1,3 +1,5 @@ +export * from './kv-cache' export * from './publisher' export * from './publisher-object' export * from './ratelimit' +export * from './workers-cache' diff --git a/packages/cloudflare/src/kv-cache.test.ts b/packages/cloudflare/src/kv-cache.test.ts new file mode 100644 index 000000000..64e2b6de3 --- /dev/null +++ b/packages/cloudflare/src/kv-cache.test.ts @@ -0,0 +1,153 @@ +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 { 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 } + } + + 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, 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('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('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() + + // 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}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('defaults to no prefix', async () => { + const store = new experimental_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() + + 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/kv-cache.ts b/packages/cloudflare/src/kv-cache.ts new file mode 100644 index 000000000..2838d86e0 --- /dev/null +++ b/packages/cloudflare/src/kv-cache.ts @@ -0,0 +1,158 @@ +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import type { Public } from '@orpc/shared' +import { RPCSerializer } from '@orpc/client' +import { encodeCacheKey } from '@orpc/experimental-cache' +import { isAsyncIteratorObject, stringifyJSON, toArray } 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 KV namespace to store entries in. + */ + kv: KVNamespace + + /** + * 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`. 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): + * 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 kv: KVNamespace + private readonly prefix: string + private readonly serializer: Public + + constructor(options: experimental_KVCacheStoreOptions) { + this.kv = options.kv + 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 && Date.now() >= 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) + + // 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 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: 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, 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: unknown): string { + return `${this.prefix}entry:${encodeCacheKey(key)}` + } + + private tagKey(tag: string): string { + return `${this.prefix}tag:${tag}` + } +} diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts new file mode 100644 index 000000000..b4c9c6492 --- /dev/null +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { experimental_WorkersCacheStore } from './workers-cache' + +describe('experimental_WorkersCacheStore', () => { + const createPurger = () => ({ + purge: vi.fn(async () => ({ success: true })), + }) + + it('always misses and stores nothing', async () => { + const purger = createPurger() + const store = new experimental_WorkersCacheStore({ cache: 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 experimental_WorkersCacheStore({ cache: 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 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( + 'experimental_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' }] })), + } + const store = new experimental_WorkersCacheStore({ cache: purger }) + + await expect(store.revalidateTag('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 new file mode 100644 index 000000000..daf8a2405 --- /dev/null +++ b/packages/cloudflare/src/workers-cache.ts @@ -0,0 +1,69 @@ +import type { CacheEntry, CacheSetOptions, CacheStore } from '@orpc/experimental-cache' +import { encodeCacheTagHeader } from '@orpc/experimental-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 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 + * the `CacheHandlerPlugin` `headers` 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 experimental_WorkersCacheStore implements CacheStore { + private readonly cache: experimental_WorkersCachePurger + + constructor(options: experimental_WorkersCacheStoreOptions) { + this.cache = options.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 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(`experimental_WorkersCacheStore 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 451d13f1f..2eaaf4fcb 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/effect/README.md b/packages/effect/README.md index 715c19de7..f265077da 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index bd4de1fb6..3c398f009 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 01580b99e..fff2e4623 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 3e03cd7f3..ac495e23b 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/nest/README.md b/packages/nest/README.md index 3cdbc723f..f99367a53 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/next/README.md b/packages/next/README.md index 4a2d3b2f5..7903d14d0 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/node/README.md b/packages/node/README.md index 48b522e53..9e4f24845 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 07e1a9171..9637a86db 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index dc5a8c632..b251b440e 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 4aa8c714d..545693d55 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/pino/README.md b/packages/pino/README.md index 6525caf59..4742c8e9e 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index b63f81938..bd2f4adc7 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 96fe62a68..c5fc9c909 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/server/README.md b/packages/server/README.md index ad32d1bc3..dcf9552cd 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/shared/README.md b/packages/shared/README.md index 285086696..82418984b 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** 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[], diff --git a/packages/swr/README.md b/packages/swr/README.md index 1f3c53d27..8752f2615 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 7e37a7065..8538044d0 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index a97e6b4f0..a68ee87f9 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index d36a6c294..4d532b053 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/packages/zod/README.md b/packages/zod/README.md index 7f61d9ba3..a8a9600e6 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://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. - [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache): Tag-based caching and revalidation with memory, Redis, and Vercel adapters. - [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation): Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). - [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. @@ -59,7 +60,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/node](https://www.npmjs.com/package/@orpc/node): [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). -- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). - [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. **Observability** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab444ca82..b4a2fbb79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,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 @@ -193,7 +196,7 @@ importers: devDependencies: '@astrojs/cloudflare': specifier: ^14.2.3 - version: 14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0) + version: 14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0) '@opentelemetry/instrumentation': specifier: ^0.221.0 version: 0.221.0(@opentelemetry/api@1.9.1)(supports-color@10.2.2) @@ -215,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 @@ -259,7 +265,7 @@ importers: version: 26.2.0 blume: specifier: ^1.5.3 - version: 1.5.3(4f37e8c30923bacb6b6775379b0a0df7) + version: 1.5.3(c78e47a34d22e794f1ae1e7f99271265) effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 @@ -342,6 +348,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.2 + version: 0.8.2 + 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': @@ -366,6 +394,9 @@ importers: '@orpc/client': specifier: workspace:* version: link:../client + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../cache '@orpc/publisher': specifier: workspace:* version: link:../publisher @@ -5867,6 +5898,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'} @@ -5883,6 +5917,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.11.0': resolution: {integrity: sha512-m1QFg+U+3yPOnP1xSYJ73UIRxLOXdts1JOhiOiyPYqEsALgrXFFINvgUaD6R6iNvaBFAjHllBCbkfx4FuOdpaA==} engines: {node: '>=20'} @@ -5900,6 +5946,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} @@ -12618,12 +12668,12 @@ snapshots: - prettier - prettier-plugin-astro - '@astrojs/cloudflare@14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0)': + '@astrojs/cloudflare@14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0)': dependencies: '@astrojs/internal-helpers': 0.10.4 '@astrojs/underscore-redirects': 1.0.4 '@cloudflare/vite-plugin': 1.53.0(vite@8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0))(wrangler@4.124.0) - astro: 7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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) piccolore: 0.1.3 vite: 8.2.2(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) wrangler: 4.124.0 @@ -12764,13 +12814,13 @@ snapshots: github-slugger: 2.0.0 satteri: 0.10.5 - '@astrojs/mdx@7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4 '@astrojs/markdown-remark': 7.2.4(supports-color@10.2.2) '@mdx-js/mdx': 3.1.1(supports-color@10.2.2) acorn: 8.18.0 - astro: 7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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.2 estree-util-visit: 2.0.0 hast-util-to-html: 9.0.5 @@ -12786,10 +12836,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@11.1.4(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4 - astro: 7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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: @@ -12834,14 +12884,14 @@ snapshots: '@astrojs/underscore-redirects@1.0.4': {} - '@astrojs/vercel@11.0.7(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.1(@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.7(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.1(@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.4 '@vercel/analytics': 1.6.1(next@16.3.1(@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.11.0(rollup@4.62.4)(supports-color@10.2.2) '@vercel/routing-utils': 5.3.3 - astro: 7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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: @@ -15851,10 +15901,10 @@ snapshots: - universal-cookie - zod - '@scalar/astro@0.4.14(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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: @@ -17556,6 +17606,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 @@ -17566,6 +17621,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.11.0(rollup@4.62.4)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) @@ -17595,6 +17656,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 @@ -18176,7 +18243,7 @@ snapshots: astring@1.9.0: {} - astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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.4 @@ -18225,7 +18292,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.2(@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.2(@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 @@ -18350,14 +18417,14 @@ snapshots: blake3-wasm@2.1.5: {} - blume@1.5.3(4f37e8c30923bacb6b6775379b0a0df7): + blume@1.5.3(c78e47a34d22e794f1ae1e7f99271265): dependencies: '@astrojs/check': 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.7 - '@astrojs/mdx': 7.0.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.7(@astrojs/markdown-satteri@0.3.7)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@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.7(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.1(@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.7(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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.1(@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) '@asyncapi/converter': 2.0.2 '@clack/prompts': 1.7.0 '@iconify-json/lucide': 1.2.124 @@ -18366,7 +18433,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.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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.14 '@scalar/openapi-types': 0.9.4 '@shikijs/transformers': 4.4.3 @@ -18376,7 +18443,7 @@ snapshots: '@types/mdast': 4.0.4 '@vercel/analytics': 2.0.1(next@16.3.1(@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.70(zod@4.4.3) - astro: 7.2.4(@astrojs/markdown-remark@7.2.4(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.4(@astrojs/markdown-remark@7.2.4(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 @@ -18432,7 +18499,7 @@ snapshots: write-file-atomic: 8.0.0 zod: 4.4.3 optionalDependencies: - '@astrojs/cloudflare': 14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0) + '@astrojs/cloudflare': 14.2.3(@types/node@26.2.0)(astro@7.2.4(@astrojs/markdown-remark@7.2.4(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)(jiti@2.7.0)(terser@5.50.0)(wrangler@4.124.0)(yaml@2.9.0) transitivePeerDependencies: - '@astrojs/markdown-remark' - '@aws-sdk/credential-provider-web-identity' @@ -24652,7 +24719,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 @@ -24664,7 +24731,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) until-async@3.0.2: {}