diff --git a/README.md b/README.md index b99bfcf21..68b6ccc67 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/apps/content/docs/api-reference.mdx b/apps/content/docs/api-reference.mdx index 13a6a2149..5cda3efae 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, Upstash, and Vercel adapters. | [Cache](/docs/helpers/cache) | | [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) | | [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/experimental-lock](https://npmx.dev/package-docs/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](/docs/helpers/lock) | @@ -51,8 +52,8 @@ For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki | [@orpc/experimental-msw](https://npmx.dev/package-docs/@orpc/experimental-msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) | | [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | | [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | -| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher, Rate Limit, and Lock. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock) | -| [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock), [Traces](/docs/integrations/cloudflare-traces) | +| [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Cache, Publisher, Rate Limit, and Lock. | [Cache](/docs/helpers/cache), [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Lock](/docs/helpers/lock) | +| [@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), [Lock](/docs/helpers/lock), [Traces](/docs/integrations/cloudflare-traces) | | [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | ## Observability diff --git a/apps/content/docs/helpers/cache.mdx b/apps/content/docs/helpers/cache.mdx new file mode 100644 index 000000000..4e3985d19 --- /dev/null +++ b/apps/content/docs/helpers/cache.mdx @@ -0,0 +1,390 @@ +--- +title: "Cache Helpers" +description: "Cache oRPC procedure output with tag-based revalidation, stale-while-revalidate, storage adapters, and a handler plugin that reflects cache tags in HTTP headers." +sidebar: + label: "Cache" +--- + +## Installation + +```package-install +npm install @orpc/experimental-cache@beta +``` + +## Basic Usage + +Everything builds on the `CacheStore` interface: `getOrSet` returns the entry under a key and fills it when there is none, and `revalidate` invalidates entries by tag. A router shares one store, passed through the request context as `cache/store`, as the `CacheContext` interface describes. Use one of these adapters or write your own: + +| Name | Adapter for | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| [`MemoryCacheStore`](#memory) | In-memory storage | +| [`RedisCacheStore`](#redis) | [Redis](https://github.com/redis/redis) | +| [`UpstashCacheStore`](#upstash) | [Upstash Redis](https://github.com/upstash/redis-js) | +| [`experimental_BunRedisCacheStore`](#bun) | [Bun's Redis](https://bun.com/docs/runtime/redis) | +| [`VercelCacheStore`](#vercel) | [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache) | +| [`CacheableCacheStore`](#cacheable) | [Cacheable](https://cacheable.org) | +| [`experimental_WorkersCacheStore`](#cloudflare-workers-caching) | [Cloudflare Workers Caching](https://developers.cloudflare.com/workers/cache/), purge only | + +```ts twoslash +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' +// ---cut--- +const store = new MemoryCacheStore() + +const entry = await store.getOrSet('planet:1', async () => ({ id: 1, name: 'Earth' }), { + tags: ['planets', 'planet:1'], + ttl: 60_000, +}) + +await store.revalidate({ tags: ['planets'] }) // the next `getOrSet` fills again +``` + +An entry is fresh for `ttl` milliseconds and kept for a further `swr` window, during which `getOrSet` still returns it with a past `expiresAt` while one caller refreshes it in the background. Revalidating a tag invalidates every entry carrying it, fresh or stale. Durations are in milliseconds throughout. + +## Cache Middleware + +The `cache` helper creates middleware that caches the output of [procedures](/docs/procedure). A hit returns the cached output without running the handler; a miss runs the handler once, even for concurrent callers, and stores the result. The `key`, `tags`, `ttl`, `swr`, and `enabled` options accept static values or functions of the middleware options and input. + +`key` defaults to the procedure path and input. When provided, it is used as given, so procedures sharing a key share an entry. + +```ts +import { cache, CacheContext } from '@orpc/experimental-cache' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' + +const findPlanet = os + .$context() + .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 in milliseconds, default is no expiry + swr: 300_000, // Optional stale-while-revalidate window in milliseconds, default is 0 + }), + ) + .handler(({ input }) => { + return { id: input.id, name: `Planet ${input.id}` } + }) + +const result = await call( + findPlanet, + { id: 1 }, + { context: { 'cache/store': new MemoryCacheStore() } }, +) +``` + +:::warning +Entries are stored only when the handler succeeds, and stores hand the output straight to their serializer. Values it cannot represent, such as [AsyncIteratorObject](/docs/async-iterator-object), readable streams, Blob, and File, do not survive the round trip, so do not cache procedures returning them. +::: + +:::warning +An entry is shared by everyone using its key. If output depends on the requester, put the distinguishing part in `key`, or resolve `enabled` to `false` to bypass caching for that request. +::: + +### Stale While Revalidate + +Past `ttl` but within `swr`, the middleware returns the stale output at once and re-runs the procedure in the background to refresh the entry. Concurrent stale hits refresh once, and nothing older than `ttl + swr` is ever served. + +On runtimes that stop pending work once the response is sent, such as Cloudflare Workers, pass `cache/waitUntil` through the context so refreshes can finish: + +```ts +export default { + async fetch(request, env, ctx) { + const { response } = await handler.handle(request, { + context: { + 'cache/store': store, + 'cache/waitUntil': ctx.waitUntil.bind(ctx), + }, + }) + + return response ?? new Response('Not Found', { status: 404 }) + }, +} +``` + +The promise it receives rejects when a refresh fails, so `cache/waitUntil` is also where those failures are handled. Without it they surface as unhandled rejections, so on other runtimes pass one that reports them, for example `promise => promise.catch(console.error)`. + +## Revalidate Middleware + +The `revalidate` helper creates middleware that revalidates tags after the procedure succeeds, typically on mutations. The required `tags` option accepts a non-empty list or a function of the middleware options and input. When the procedure throws, or `tags` resolves to `null` or `undefined`, nothing is revalidated. When the store fails to revalidate, the request fails even though the mutation already ran, so the failure is visible; retrying such a request repeats the mutation. + +```ts +import { revalidate } from '@orpc/experimental-cache' + +const updatePlanet = os + .$context() + .input(z.object({ id: z.number(), name: z.string() })) + .use( + revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }), + ) + .handler(({ input }) => { + return input + }) +``` + +## Handler Plugin + +The `CacheHandlerPlugin` reflects the activity of [Cache Middleware](#cache-middleware) and [Revalidate Middleware](#revalidate-middleware) into response headers. Only the headers you list are set: + +- `orpc-cache-tag` carries the tags the response depends on. +- `orpc-cache-tag-invalidation` carries the tags the request revalidated, for invalidating tagged data in client caches. +- `cache-control` and `cache-tag` are the standard HTTP counterparts for response caches in front, such as CDNs or Cloudflare Workers Caching. + +The plugin sets these over anything already on the response. To override them, set your own afterwards with [ResponseHeadersPlugin](/docs/plugins/response-headers). + +Tags are joined with commas. Only `%`, `,`, uppercase letters, and characters that cannot appear in a header value are percent-encoded, so typical tags stay readable. Uppercase letters are encoded because caches like Cloudflare Workers Caching match tags case-insensitively, and the encoded form stays unambiguous under case folding. `decodeCacheTagHeader` from `@orpc/shared` parses a header back into tags. + +```ts +import { CacheHandlerPlugin } from '@orpc/experimental-cache' + +const handler = new RPCHandler(router, { + plugins: [ + new CacheHandlerPlugin({ + headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'], + }), + ], +}) +``` + +:::info[Response Caches in Front] +With `cache-control` and `cache-tag` listed, a response cache in front serves cached responses without invoking your server. Pair it with a purge-capable store, such as `experimental_WorkersCacheStore`, so revalidations purge the front cache too. Standard HTTP caches only store GET and HEAD responses, so this mainly benefits [OpenAPIHandler](/docs/openapi/handler) routes; RPC requests use POST. + +`cache-control` uses `max-age`, not `s-maxage`, because [`s-maxage` carries `proxy-revalidate` semantics](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10) that forbid the stale reuse `swr` asks for. It therefore reaches browser caches too, which no tag purge can invalidate. Set your own `cache-control` when responses must stay out of them. +::: + +:::info +When a procedure calls other procedures, only the first cache check and the first revalidation of the procedure the client called are reflected, so nested procedures never leak their tags into the response. Headers appear only on successful responses. +::: + +:::tip[Cross-Origin Clients] +The headers carry oRPC-specific names on purpose: CDN conventions like `Cache-Tag` can be consumed and stripped by intermediaries before reaching the browser, while these always arrive intact. For cross-origin browser clients, list them in [CORSPlugin](/docs/plugins/cors)'s `exposeHeaders` so client code can read them: + +```ts +new CORSPlugin({ + exposeHeaders: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'], +}) +``` + +::: + +:::info +The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. +::: + +## Tiered Store + +`TieredCacheStore` layers several stores front to back, such as a memory store in front of a Redis one. A miss in one tier fills from the next, so only the last tier runs the procedure, and a revalidation reaches every tier. Each tier measures `ttl` from when it stored its copy, so cap it on front tiers to bound how long they may outlive the entry behind them. + +```ts +import { TieredCacheStore } from '@orpc/experimental-cache' +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' +import { RedisCacheStore } from '@orpc/experimental-cache/redis' +import { createClient } from 'redis' + +const store = new TieredCacheStore([ + { store: new MemoryCacheStore(), ttl: 5_000 }, + { store: new RedisCacheStore(createClient({ url: process.env.REDIS_URL })) }, +]) +``` + +## Adapters + +### Memory + +Stores entries in the process. Suited to development, testing, and single-instance deployments. + +```ts +import { MemoryCacheStore } from '@orpc/experimental-cache/memory' + +const store = new MemoryCacheStore({ + /** + * Serializer used to encode non-string keys. + * + * @default RPCJsonSerializer + */ + serializer: undefined, +}) +``` + +### Redis + +Stores entries as JSON strings retained for `ttl + swr`, and one counter per tag that revalidation increments, using single-key commands only, so it works with standalone servers and Redis Cluster alike. A hit costs one round trip when the lookup's tags match the entry's, and a miss three. Fills are coalesced by the `locker`, within the process by default; a [`RedisLocker`](/docs/helpers/lock) on the same server coalesces them across processes. The client is connected lazily when needed. + +```ts +import { RedisCacheStore } from '@orpc/experimental-cache/redis' +import { RedisLocker } from '@orpc/experimental-lock/redis' +import { createClient } from 'redis' + +const client = createClient({ url: 'redis://localhost:6379' }) + +const store = new RedisCacheStore(client, { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for keys and cached outputs. + * + * @default RPCJsonSerializer + */ + serializer: undefined, + + /** + * Coalesces concurrent fills of one key. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker: new RedisLocker(client, { prefix: 'cache-lock:', ttl: 10_000 }), +}) +``` + +The Redis, Upstash, and Bun stores share `BaseRedisCacheStore` from `@orpc/experimental-cache/base-redis`, which holds the key layout and the flow. A store for another Redis-compatible client only has to implement five commands. + +### Upstash + +The [Redis](#redis) adapter for Upstash's REST client. It shares the key and entry format with `RedisCacheStore`, so both can serve the same database, and pairs with the [`UpstashLocker`](/docs/helpers/lock) to coalesce fills across instances. + +```ts +import { UpstashCacheStore } from '@orpc/experimental-cache/upstash' +import { UpstashLocker } from '@orpc/experimental-lock/upstash' +import { Redis } from '@upstash/redis' + +const redis = Redis.fromEnv() + +const store = new UpstashCacheStore(redis, { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for keys and cached outputs. + * + * @default RPCJsonSerializer + */ + serializer: undefined, + + /** + * Coalesces concurrent fills of one key. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker: new UpstashLocker(redis, { prefix: 'cache-lock:', ttl: 10_000 }), +}) +``` + +### Bun + +The [Redis](#redis) adapter for Bun's built-in Redis client, from `@orpc/bun`. It shares the key and entry format with `RedisCacheStore`, so both can serve the same database, and pairs with the [`experimental_BunRedisLocker`](/docs/helpers/lock) to coalesce fills across processes. + +```ts +import { experimental_BunRedisCacheStore, experimental_BunRedisLocker } from '@orpc/bun' +import { redis } from 'bun' + +const store = new experimental_BunRedisCacheStore(redis, { + /** + * The prefix to use for Redis keys. + * + * @default undefined + */ + prefix: undefined, + + /** + * Serializer for keys and cached outputs. + * + * @default RPCJsonSerializer + */ + serializer: undefined, + + /** + * Coalesces concurrent fills of one key. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker: new experimental_BunRedisLocker(redis, { prefix: 'cache-lock:', ttl: 10_000 }), +}) +``` + +### Vercel + +Stores entries in the [Vercel Runtime Cache](https://vercel.com/docs/caching/runtime-cache), expiring tags natively through `expireTag`. Outside Vercel, the default `getCache()` falls back to an in-memory cache. + +```ts +import { VercelCacheStore } from '@orpc/experimental-cache/vercel' +import { getCache } from '@vercel/functions' + +const store = new VercelCacheStore({ + /** + * The Vercel Runtime Cache to use. + * + * @default getCache() + */ + cache: getCache(), + + /** + * Serializer for keys and cached outputs. + * + * @default RPCJsonSerializer + */ + serializer: undefined, + + /** + * Coalesces concurrent fills of one key within the process. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker: undefined, +}) +``` + +### Cacheable + +Stores entries through a [Cacheable](https://cacheable.org) instance, so its primary and secondary stores back the cache, such as a memory tier in front of any Keyv store. Tags are invalidated natively through its tag service, which the store enables. + +```ts +import { CacheableCacheStore } from '@orpc/experimental-cache/cacheable' +import { Cacheable, Keyv } from 'cacheable' + +const cacheable = new Cacheable({ + secondary: new Keyv('redis://localhost:6379'), +}) + +const store = new CacheableCacheStore(cacheable, { + /** + * Serializer for keys and cached outputs. + * + * @default RPCJsonSerializer + */ + serializer: undefined, + + /** + * Coalesces concurrent fills of one key within the process. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker: undefined, +}) +``` + +### Cloudflare Workers Caching + +A purge-only store from `@orpc/cloudflare`. [Workers Caching](https://developers.cloudflare.com/workers/cache/) caches whole responses in front of the Worker through the `cache-control` and `cache-tag` headers of the [Handler Plugin](#handler-plugin), so every `getOrSet` runs the procedure and stores nothing, and `revalidate` purges the tags from the front cache. + +It requires `"cache": { "enabled": true }` in your wrangler configuration. Purges are scoped to the calling entrypoint, tags match case-insensitively, and purge calls use the Free tier rate limits regardless of your plan. + +```ts +import { experimental_WorkersCacheStore as WorkersCacheStore } from '@orpc/cloudflare' + +const store = new WorkersCacheStore({ + /** + * The Workers Caching purge surface, such as `ctx.cache`. + * + * @default cache from `cloudflare:workers` + */ + cache: undefined, +}) +``` diff --git a/apps/content/package.json b/apps/content/package.json index 678cff47e..f16a01198 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/experimental-lock": "workspace:*", "@orpc/openapi": "workspace:*", "@orpc/opentelemetry": "workspace:*", diff --git a/eslint.config.js b/eslint.config.js index c4f2864ac..9d340b133 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,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 b8fb354b1..3d9a2ab0c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@orpc/client": "workspace:*", "@orpc/contract": "workspace:*", "@orpc/evlog": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/experimental-effect": "workspace:*", "@orpc/experimental-lock": "workspace:*", "@orpc/experimental-msw": "workspace:*", diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 4d106630e..7ca09736c 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/arktype/README.md b/packages/arktype/README.md index 45ecb1106..5af656f20 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/bun/README.md b/packages/bun/README.md index 02258660f..13b3cc2d5 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -43,13 +43,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/bun/package.json b/packages/bun/package.json index c8f68fbd7..f51a0cd93 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -2,7 +2,7 @@ "name": "@orpc/bun", "type": "module", "version": "2.0.0-beta.35", - "description": "Bun integration for oRPC: Redis-backed pub/sub, rate limiting, and locking using Bun's built-in Redis client", + "description": "Bun integration for oRPC: Redis-backed pub/sub, rate limiting, locking, and caching using Bun's built-in Redis client", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", @@ -20,6 +20,7 @@ "redis", "pubsub", "ratelimit", + "cache", "lock", "typescript" ], @@ -49,6 +50,7 @@ }, "dependencies": { "@orpc/client": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/experimental-lock": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", diff --git a/packages/bun/src/index.test.ts b/packages/bun/src/index.test.ts index 128424dc7..acec117e1 100644 --- a/packages/bun/src/index.test.ts +++ b/packages/bun/src/index.test.ts @@ -1,9 +1,10 @@ import { expect, it } from 'bun:test' -it('exports BunRedisRateLimiter, BunRedisPublisher, experimental_BunRedisLocker', async () => { +it('exports BunRedisRateLimiter, BunRedisPublisher, experimental_BunRedisCacheStore, experimental_BunRedisLocker', async () => { await expect(import('./index')).resolves.toMatchObject({ BunRedisRateLimiter: expect.any(Function), BunRedisPublisher: expect.any(Function), + experimental_BunRedisCacheStore: expect.any(Function), experimental_BunRedisLocker: expect.any(Function), }) }) diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 047910569..e2edc2d0d 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -1,3 +1,4 @@ +export * from './redis-cache' export * from './redis-lock' export * from './redis-publisher' export * from './redis-ratelimit' diff --git a/packages/bun/src/redis-cache.test.ts b/packages/bun/src/redis-cache.test.ts new file mode 100644 index 000000000..d208fc46f --- /dev/null +++ b/packages/bun/src/redis-cache.test.ts @@ -0,0 +1,248 @@ +import { RPCJsonSerializer } from '@orpc/client' +import { sleep, stringifyJSON } from '@orpc/shared' +import { RedisClient } from 'bun' +import { beforeAll, describe, expect, it, mock, spyOn } from 'bun:test' +import { waitFor } from '../tests/__shared__/utils' +import { experimental_BunRedisCacheStore } from './redis-cache' +import { experimental_BunRedisLocker } from './redis-lock' + +const REDIS_URL = Bun.env.REDIS_URL + +describe.skipIf(!REDIS_URL)('bun redis cache store integration', () => { + const redis = new RedisClient(REDIS_URL) + + // Closing the client here breaks the next file's client on Bun 1.4; the process exit closes it. + beforeAll(async () => { + await redis.connect() + }) + + function createTestingStore(options: ConstructorParameters[1] = {}) { + const prefix = options.prefix ?? `orpc-bun-redis-cache-store-${crypto.randomUUID()}:` + return { store: new experimental_BunRedisCacheStore(redis, { ...options, prefix }), prefix } + } + + function createLocker(options: { prefix: string, ttl: number, timeout: number }) { + return new experimental_BunRedisLocker(redis, options) + } + + it('fills a miss once, then serves the entry with its tags and expiresAt', async () => { + const { store } = createTestingStore() + const fill = mock(async () => ({ nested: [1, 2] })) + + const first = await store.getOrSet('k', fill, { tags: ['t'], ttl: 120_000 }) + expect(first.output).toEqual({ nested: [1, 2] }) + expect(first.tags).toEqual(['t']) + expect(first.expiresAt).toBeGreaterThan(Date.now()) + + await expect(store.getOrSet('k', fill, { tags: ['t'], ttl: 120_000 })).resolves.toEqual(first) + expect(fill).toHaveBeenCalledTimes(1) + + await store.getOrSet('u', async () => undefined) + await expect(store.getOrSet('u', async () => 'refilled')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined, evictAt: undefined }) + }, { timeout: 20_000 }) + + 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.getOrSet('k', async () => output) + await expect(store.getOrSet('k', async () => 'refilled')).resolves.toMatchObject({ output }) + }, { timeout: 20_000 }) + + it('invalidates entries by any of their tags, and keeps ones filled afterwards', async () => { + const { store } = createTestingStore() + + await store.getOrSet('multi', async () => 'v', { tags: ['a', 'b'] }) + await store.getOrSet('other', async () => 'v', { tags: ['c'] }) + + await store.revalidate({ tags: ['a', 'b'] }) + + await expect(store.getOrSet('multi', async () => 'new', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) + await expect(store.getOrSet('other', async () => 'refilled', { tags: ['c'] })).resolves.toMatchObject({ output: 'v' }) + await expect(store.getOrSet('multi', async () => 'newer', { tags: ['a'] })).resolves.toMatchObject({ output: 'new' }) + }, { timeout: 20_000 }) + + it('supports a custom serializer', async () => { + const serializer = new RPCJsonSerializer() + const serializeSpy = spyOn(serializer, 'serialize') + const deserializeSpy = spyOn(serializer, 'deserialize') + const { store } = createTestingStore({ serializer }) + + await store.getOrSet('k', async () => ({ a: 1 })) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() + }, { timeout: 20_000 }) + + it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { + const { store } = createTestingStore() + + await store.getOrSet('no-swr', async () => 'v', { ttl: 1000 }) + await store.getOrSet('swr', async () => 'v', { ttl: 1000, swr: 10_000 }) + + await sleep(1500) + + await expect(store.getOrSet('no-swr', async () => 'refilled', { ttl: 1000 })).resolves.toMatchObject({ output: 'refilled' }) + + const waitUntil = mock((_promise: Promise) => {}) + const stale = await store.getOrSet('swr', async () => 'fresh', { ttl: 1000, swr: 10_000, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(Date.now()) + + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + const fresh = await store.getOrSet('swr', async () => 'other', { ttl: 1000, swr: 10_000 }) + expect(fresh.output).toBe('fresh') + expect(fresh.expiresAt).toBeGreaterThan(stale.expiresAt!) + }, { timeout: 20_000 }) + + it('stores entries as strings and tag counters under the prefixed key families', async () => { + const { store, prefix } = createTestingStore() + + await store.getOrSet('k', async () => 'v', { tags: ['t'] }) + await expect(redis.send('TYPE', [`${prefix}e:k`])).resolves.toBe('string') + await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(false) + + await store.revalidate({ tags: ['t'] }) + await expect(redis.send('TYPE', [`${prefix}t:t`])).resolves.toBe('string') + + const unprefixed = new experimental_BunRedisCacheStore(redis) + const key = crypto.randomUUID() + await unprefixed.getOrSet(key, async () => 'v') + await expect(redis.exists(`e:${key}`)).resolves.toBe(true) + }, { timeout: 20_000 }) + + it('validates against the tags stored with the entry, treating tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createTestingStore() + + await redis.set(`${prefix}e:k`, stringifyJSON({ output: { json: 'v' }, tags: ['stored'] })!) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: 'v', tags: ['stored'] }) + await expect(store.getOrSet('k', async () => 'other', { tags: ['other'] })).resolves.toMatchObject({ output: 'v', tags: ['stored'] }) + + await store.revalidate({ tags: ['stored'] }) + await expect(store.getOrSet('k', async () => 'refilled', { tags: ['other'] })).resolves.toMatchObject({ output: 'refilled', tags: ['other'] }) + }, { timeout: 20_000 }) + + it('evicts entries past evictAt that the server still holds', async () => { + const { store, prefix } = createTestingStore() + + await redis.set(`${prefix}e:k`, stringifyJSON({ output: { json: 'v' }, expiresAt: 1, evictAt: 1 })!) + + await expect(store.getOrSet('k', async () => 'refilled')).resolves.toMatchObject({ output: 'refilled', expiresAt: undefined }) + }, { timeout: 20_000 }) + + it('rejects entries it cannot parse', async () => { + const { store, prefix } = createTestingStore() + + await redis.set(`${prefix}e:broken`, 'not json') + + await expect(store.getOrSet('broken', async () => 'v')).rejects.toThrow() + }, { timeout: 20_000 }) + + it('encodes non-string keys stably', async () => { + const { store } = createTestingStore() + + await store.getOrSet([['planet', 'find'], { b: 2, a: 1 }], async () => 'v') + + await expect(store.getOrSet([['planet', 'find'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.getOrSet([['planet', 'find'], { a: 1, b: 3 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) + }, { timeout: 20_000 }) + + it('fills once for concurrent callers of one key, and lets a waiter fill when the holder failed', async () => { + const { store } = createTestingStore() + let finish!: (output: string) => void + const fill = mock(() => new Promise((resolve) => { + finish = resolve + })) + + const pending = Promise.all([store.getOrSet('k', fill), store.getOrSet('k', fill), store.getOrSet('k', fill)]) + await waitFor(() => expect(fill).toHaveBeenCalledTimes(1), { timeout: 5000 }) + finish('v') + + const entries = await pending + expect(entries.map(entry => entry.output)).toEqual(['v', 'v', 'v']) + expect(fill).toHaveBeenCalledTimes(1) + + let fail!: (error: Error) => void + let started!: () => void + const holding = new Promise((resolve) => { + started = resolve + }) + const first = store.getOrSet('failing', () => { + started() + return new Promise((_, reject) => { + fail = reject + }) + }) + await holding + const second = store.getOrSet('failing', async () => 'fresh') + fail(new Error('handler down')) + + await expect(first).rejects.toThrow('handler down') + await expect(second).resolves.toMatchObject({ output: 'fresh' }) + }, { timeout: 20_000 }) + + it('drops output computed before a revalidation that landed during its fill', async () => { + const { store } = createTestingStore() + let finish!: (output: string) => void + let started!: () => void + const filling = new Promise((resolve) => { + started = resolve + }) + + const first = store.getOrSet('k', () => { + started() + return new Promise((resolve) => { + finish = resolve + }) + }, { tags: ['t'] }) + await filling + await store.revalidate({ tags: ['t'] }) + finish('outdated') + + await expect(first).resolves.toMatchObject({ output: 'outdated' }) + await expect(store.getOrSet('k', async () => 'fresh', { tags: ['t'] })).resolves.toMatchObject({ output: 'fresh' }) + }, { timeout: 20_000 }) + + it('coalesces fills across stores sharing a locker, and lets a waiter fill once its wait times out', async () => { + const lockPrefix = `${crypto.randomUUID()}:` + const { store: first, prefix } = createTestingStore({ locker: createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 5000 }) }) + const { store: second } = createTestingStore({ prefix, locker: createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 300 }) }) + const { store: third } = createTestingStore({ prefix, locker: createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 5000 }) }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const fill = mock(async () => { + await held + return 'held' + }) + + const holder = first.getOrSet('k', fill) + await waitFor(() => expect(fill).toHaveBeenCalledTimes(1), { timeout: 5000 }) + + let settled = false + const timedOut = second.getOrSet('k', async () => 'waiter').then((entry) => { + settled = true + return entry + }) + const waiterFill = mock(async () => 'third') + const waiter = third.getOrSet('k', waiterFill) + await sleep(100) + expect(settled).toBe(false) + await expect(timedOut).resolves.toMatchObject({ output: 'waiter' }) + + release() + await expect(holder).resolves.toMatchObject({ output: 'held' }) + await expect(waiter).resolves.toMatchObject({ output: 'held' }) + expect(waiterFill).not.toHaveBeenCalled() + }, { timeout: 20_000 }) +}) diff --git a/packages/bun/src/redis-cache.ts b/packages/bun/src/redis-cache.ts new file mode 100644 index 000000000..d0f9007ec --- /dev/null +++ b/packages/bun/src/redis-cache.ts @@ -0,0 +1,38 @@ +import type { BaseRedisCacheStoreOptions } from '@orpc/experimental-cache/base-redis' +import type { RedisClient } from 'bun' +import { BaseRedisCacheStore } from '@orpc/experimental-cache/base-redis' + +export type experimental_BunRedisCacheStoreOptions = BaseRedisCacheStoreOptions + +/** + * Cache store adapter for Bun's built-in Redis client. Shares its key and + * entry format with `RedisCacheStore`, so both can serve the same database. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class experimental_BunRedisCacheStore extends BaseRedisCacheStore { + constructor( + private readonly redis: RedisClient, + options: experimental_BunRedisCacheStoreOptions = {}, + ) { + super(options) + } + + protected get(key: string): Promise { + return this.redis.get(key) + } + + protected getMany(keys: string[]): Promise { + return this.redis.send('MGET', keys) as Promise + } + + protected set(key: string, value: string, px: number | undefined): Promise { + return px === undefined + ? this.redis.set(key, value) + : this.redis.send('SET', [key, value, 'PX', String(px)]) + } + + protected increment(key: string): Promise { + return this.redis.incr(key) + } +} diff --git a/packages/bun/tests/cache-redis-adapters-compatibility.test.ts b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts new file mode 100644 index 000000000..c25e3a2aa --- /dev/null +++ b/packages/bun/tests/cache-redis-adapters-compatibility.test.ts @@ -0,0 +1,127 @@ +import type { CacheStore } from '@orpc/experimental-cache' +import { RedisCacheStore } from '@orpc/experimental-cache/redis' +import { RedisLocker } from '@orpc/experimental-lock/redis' +import { sleep } from '@orpc/shared' +import { RedisClient } from 'bun' +import { afterAll, describe, expect, it } from 'bun:test' +import { createClient } from 'redis' +import { experimental_BunRedisCacheStore } from '../src/redis-cache' +import { experimental_BunRedisLocker } from '../src/redis-lock' + +const REDIS_URL = Bun.env.REDIS_URL + +/** + * These tests require a real Redis server. Set `REDIS_URL` before running them. + * + * When adding new tests, always use unique keys to avoid conflicts with other cases. + * + * All adapters must connect to the same server. + */ +const stores: Array<{ name: string, store: CacheStore }> = [] +const prefix = `redis-adapters:${crypto.randomUUID()}:` +const lockPrefix = `${prefix}lock:` + +if (REDIS_URL) { + const redis = createClient({ url: REDIS_URL }) + const bunRedis = new RedisClient(REDIS_URL) + + afterAll(() => { + redis.close() + bunRedis.close() + }) + + stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix, locker: new RedisLocker(redis, { prefix: lockPrefix, ttl: 10_000 }) }) }) + stores.push({ name: 'bun redis', store: new experimental_BunRedisCacheStore(bunRedis, { prefix, locker: new experimental_BunRedisLocker(bunRedis, { prefix: lockPrefix, ttl: 10_000 }) }) }) +} + +describe('cache redis adapters compatibility', () => { + describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { + for (const source of stores) { + for (const target of stores) { + if (source === target) { + continue + } + + it(`shares entries and revalidations: ${source.name} → ${target.name}`, async () => { + const id = crypto.randomUUID() + const tag = `tag:${crypto.randomUUID()}` + const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } + + await source.store.getOrSet([['planet', 'find'], { b: 2, id }], async () => output, { tags: [tag], ttl: 60_000 }) + + const entry = await target.store.getOrSet([['planet', 'find'], { id, b: 2 }], async () => 'refilled', { tags: [tag], ttl: 60_000 }) + expect(entry.output).toEqual(output) + expect(entry.tags).toEqual([tag]) + expect(entry.expiresAt).toBeGreaterThan(Date.now()) + + await target.store.revalidate({ tags: [tag] }) + + await expect(source.store.getOrSet([['planet', 'find'], { b: 2, id }], async () => 'refilled', { tags: [tag] })).resolves.toMatchObject({ output: 'refilled' }) + }, { timeout: 20_000 }) + + it(`shares tag counters: ${source.name} → ${target.name}`, async () => { + const key = `counter:${crypto.randomUUID()}` + const tag = `tag:${crypto.randomUUID()}` + + await source.store.getOrSet(key, async () => 'v1', { tags: [tag] }) + await target.store.revalidate({ tags: [tag] }) + + await expect(target.store.getOrSet(key, async () => 'v2', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + await expect(source.store.getOrSet(key, async () => 'v3', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + + await source.store.revalidate({ tags: [tag] }) + await expect(target.store.getOrSet(key, async () => 'v4', { tags: [tag] })).resolves.toMatchObject({ output: 'v4' }) + }, { timeout: 20_000 }) + + it(`shares retention: ${source.name} → ${target.name}`, async () => { + const noSwr = `no-swr:${crypto.randomUUID()}` + const swr = `swr:${crypto.randomUUID()}` + + await source.store.getOrSet(noSwr, async () => 'v', { ttl: 1000 }) + await source.store.getOrSet(swr, async () => 'v', { ttl: 1000, swr: 10_000 }) + + await sleep(1500) + + await expect(target.store.getOrSet(noSwr, async () => 'refilled', { ttl: 1000 })).resolves.toMatchObject({ output: 'refilled' }) + + const waitUntil = (_promise: Promise) => {} + const stale = await target.store.getOrSet(swr, async () => 'refilled', { ttl: 1000, swr: 10_000, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(Date.now()) + }, { timeout: 20_000 }) + + it(`shares locks: ${source.name} → ${target.name}`, async () => { + const key = `lock:${crypto.randomUUID()}` + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let acquired!: () => void + const holding = new Promise((resolve) => { + acquired = resolve + }) + + const holder = source.store.getOrSet(key, async () => { + acquired() + await held + return 'held' + }) + await holding + + let settled = false + const waiter = target.store.getOrSet(key, async () => 'refilled').then((entry) => { + settled = true + return entry + }) + + await sleep(300) + expect(settled).toBe(false) + + release() + await holder + await expect(waiter).resolves.toMatchObject({ output: 'held' }) + }, { timeout: 20_000 }) + } + } + }) +}) diff --git a/packages/cache/README.md b/packages/cache/README.md new file mode 100644 index 000000000..34c56d265 --- /dev/null +++ b/packages/cache/README.md @@ -0,0 +1,170 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ + + +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +| Package | Purpose | Docs | +| -------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/contract](https://www.npmjs.com/package/@orpc/contract) | Define API contract as the single source of truth. | [Procedure Contract](https://orpc.dev/docs/contract/procedure), [Contract Router](https://orpc.dev/docs/contract/router), [Implementation](https://orpc.dev/docs/contract/implementation), [Generate from OpenAPI](https://orpc.dev/docs/contract/generate-from-openapi) | +| [@orpc/server](https://www.npmjs.com/package/@orpc/server) | Build APIs or implement contracts. | [Procedure](https://orpc.dev/docs/procedure), [Router](https://orpc.dev/docs/router), [Middleware](https://orpc.dev/docs/middleware), [Context](https://orpc.dev/docs/context), [RPC Handler](https://orpc.dev/docs/rpc/handler) | +| [@orpc/client](https://www.npmjs.com/package/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](https://orpc.dev/docs/client/client-side), [Server-Side Clients](https://orpc.dev/docs/client/server-side), [RPC Link](https://orpc.dev/docs/rpc/link), [Error Handling](https://orpc.dev/docs/client/error-handling) | +| [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](https://orpc.dev/docs/openapi/handler), [OpenAPI Link](https://orpc.dev/docs/openapi/link), [Specification](https://orpc.dev/docs/openapi/specification), [Scalar](https://orpc.dev/docs/openapi/scalar) | + +**Schema validation** + +| Package | Purpose | Docs | +| ------------------------------------------------------------ | ----------------------------------------------- | ----------------------------------------------------- | +| [@orpc/zod](https://www.npmjs.com/package/@orpc/zod) | Integrate with [Zod](https://zod.dev/). | [Zod](https://orpc.dev/docs/integrations/zod) | +| [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot) | Integrate with [Valibot](https://valibot.dev/). | [Valibot](https://orpc.dev/docs/integrations/valibot) | +| [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype) | Integrate with [ArkType](https://arktype.io/). | [ArkType](https://orpc.dev/docs/integrations/arktype) | + +**Built-in features** + +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | + +**Framework & ecosystem integrations** + +| Package | Purpose | Docs | +| ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [@orpc/next](https://www.npmjs.com/package/@orpc/next) | Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). | [Next.js](https://orpc.dev/docs/integrations/next) | +| [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk) | Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. | [AI SDK](https://orpc.dev/docs/integrations/ai-sdk) | +| [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query) | Integrate with [TanStack Query](https://tanstack.com/query/latest). | [TanStack Query](https://orpc.dev/docs/integrations/tanstack-query) | +| [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada) | Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). | [Pinia Colada](https://orpc.dev/docs/integrations/pinia-colada) | +| [@orpc/swr](https://www.npmjs.com/package/@orpc/swr) | Integrate with [SWR](https://swr.vercel.app/). | [SWR](https://orpc.dev/docs/integrations/swr) | +| [@orpc/experimental-msw](https://www.npmjs.com/package/@orpc/experimental-msw) | Mock procedures with [Mock Service Worker](https://mswjs.io/). | [MSW](https://orpc.dev/docs/integrations/msw) | +| [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect) | Integrate with [Effect](https://effect.website/). | [Effect](https://orpc.dev/docs/integrations/effect) | +| [@orpc/nest](https://www.npmjs.com/package/@orpc/nest) | Implement your contract with [NestJS](https://nestjs.com/). | [NestJS](https://orpc.dev/docs/integrations/nest) | +| [@orpc/node](https://www.npmjs.com/package/@orpc/node) | [Node.js](https://nodejs.org/) plugins for static file serving and large uploads. | [Static File](https://orpc.dev/docs/plugins/static-file), [Tmp File Upload](https://orpc.dev/docs/plugins/tmp-file-upload), [Batch Response Compression](https://orpc.dev/docs/plugins/batch-response-compression) | +| [@orpc/bun](https://www.npmjs.com/package/@orpc/bun) | Adapters for [Bun's Redis](https://bun.sh/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare) | Adapters for [Cloudflare Workers](https://developers.cloudflare.com/workers/). | [Cache](https://orpc.dev/docs/helpers/cache), [Publisher](https://orpc.dev/docs/helpers/publisher), [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc) | Reuse existing [tRPC](https://trpc.io/) routers within oRPC. | [tRPC](https://orpc.dev/docs/integrations/trpc) | + +**Observability** + +| Package | Purpose | Docs | +| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry) | Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. | [OpenTelemetry](https://orpc.dev/docs/integrations/opentelemetry) | +| [@orpc/pino](https://www.npmjs.com/package/@orpc/pino) | Integrate with [Pino](https://getpino.io/) for logging. | [Pino](https://orpc.dev/docs/integrations/pino) | +| [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog) | Integrate with [Evlog](https://evlog.dev/) for logging. | [Evlog](https://orpc.dev/docs/integrations/evlog) | + +## Sponsors + +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..c0b841786 --- /dev/null +++ b/packages/cache/package.json @@ -0,0 +1,122 @@ +{ + "name": "@orpc/experimental-cache", + "type": "module", + "version": "2.0.0-beta.35", + "description": "Tag-based caching and revalidation for oRPC procedures, with memory, Redis, Upstash, Vercel, and Cacheable 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", + "upstash", + "vercel", + "cacheable", + "middleware", + "api", + "typescript" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + }, + "./base-redis": { + "types": "./dist/adapters/base-redis.d.mts", + "import": "./dist/adapters/base-redis.mjs", + "default": "./dist/adapters/base-redis.mjs" + }, + "./cacheable": { + "types": "./dist/adapters/cacheable.d.mts", + "import": "./dist/adapters/cacheable.mjs", + "default": "./dist/adapters/cacheable.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" + }, + "./upstash": { + "types": "./dist/adapters/upstash.d.mts", + "import": "./dist/adapters/upstash.mjs", + "default": "./dist/adapters/upstash.mjs" + }, + "./vercel": { + "types": "./dist/adapters/vercel.d.mts", + "import": "./dist/adapters/vercel.mjs", + "default": "./dist/adapters/vercel.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./base-redis": "./src/adapters/base-redis.ts", + "./cacheable": "./src/adapters/cacheable.ts", + "./memory": "./src/adapters/memory.ts", + "./redis": "./src/adapters/redis.ts", + "./upstash": "./src/adapters/upstash.ts", + "./vercel": "./src/adapters/vercel.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "prepack": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "@upstash/redis": ">=1.35.6", + "@vercel/functions": ">=2.1.0", + "cacheable": ">=2.5.0", + "redis": ">=6.0.0" + }, + "peerDependenciesMeta": { + "@upstash/redis": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "cacheable": { + "optional": true + }, + "redis": { + "optional": true + } + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/experimental-lock": "workspace:*", + "@orpc/server": "workspace:*", + "@orpc/shared": "workspace:*", + "@standard-server/core": "~0.9.0" + }, + "devDependencies": { + "@upstash/redis": "^1.38.2", + "@vercel/functions": "^3.9.5", + "cacheable": "^2.5.0", + "redis": "^6.2.1" + } +} diff --git a/packages/cache/src/adapters/base-key-value.ts b/packages/cache/src/adapters/base-key-value.ts new file mode 100644 index 000000000..c86d5dcd3 --- /dev/null +++ b/packages/cache/src/adapters/base-key-value.ts @@ -0,0 +1,147 @@ +import type { RPCJsonSerialization } from '@orpc/client' +import type { Locker } from '@orpc/experimental-lock' +import type { Promisable, Public } from '@orpc/shared' +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions, CacheStore } from '../types' +import { RPCJsonSerializer } from '@orpc/client' +import { LockTimeoutError } from '@orpc/experimental-lock' +import { MemoryLocker } from '@orpc/experimental-lock/memory' +import { encodeCacheKey, isCacheEntryEvicted, isCacheEntryStale, resolveCacheExpiry } from '../utils' + +/** + * What a backend stores for an entry: the output serialized for JSON, the + * tags, and the lifetime. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export interface CacheEnvelope { + output: RPCJsonSerialization + tags?: readonly string[] | undefined + expiresAt?: number | undefined + evictAt?: number | undefined +} + +export interface BaseKeyValueCacheStoreOptions { + /** + * Serializer for keys, and for cached outputs where the backend stores + * them serialized. + * + * @default RPCJsonSerializer + */ + serializer?: undefined | Public + + /** + * Coalesces concurrent fills of one key, so a miss runs the fill once. The + * default shares locks within the process; a shared locker such as + * `RedisLocker` from `@orpc/experimental-lock` shares them across + * processes. A caller that times out waiting fills on its own. + * + * @default new MemoryLocker() + */ + locker?: undefined | Locker +} + +/** + * Cache store over a key-value backend, coalescing concurrent fills of one + * key through a locker. Subclasses read entries by their encoded key and + * fill the missing ones, encoding outputs as envelopes where the backend + * stores them serialized. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export abstract class BaseKeyValueCacheStore implements CacheStore { + protected readonly serializer: Public + protected readonly locker: Locker + + constructor(options: BaseKeyValueCacheStoreOptions = {}) { + this.serializer = options.serializer ?? new RPCJsonSerializer() + this.locker = options.locker ?? new MemoryLocker() + } + + async getOrSet(key: unknown, compute: () => Promise, options: CacheGetOrSetOptions = {}): Promise { + const encodedKey = encodeCacheKey(key, this.serializer) + const entry = await this.read(encodedKey, options) + + if (entry === undefined || isCacheEntryEvicted(entry)) { + return this.lock(encodedKey, async (waited) => { + const current = waited ? await this.read(encodedKey, options) : undefined + + return current === undefined || isCacheEntryEvicted(current) + ? this.fill(encodedKey, compute, options) + : current + }, () => this.fill(encodedKey, compute, options)) + } + + if (isCacheEntryStale(entry)) { + const refresh = this.lock(encodedKey, async (waited) => { + const current = waited ? await this.read(encodedKey, options) : undefined + + if (current === undefined || isCacheEntryStale(current)) { + await this.fill(encodedKey, compute, options) + } + }, () => undefined) + + options.waitUntil?.(refresh) + } + + return entry + } + + abstract revalidate(options: CacheRevalidateOptions): Promise + + /** + * Reads the entry under `encodedKey`, dropping it when it was revalidated. + * `options` are those of the lookup, so a backend can fetch what it needs + * to validate the entry alongside it. + */ + protected abstract read(encodedKey: string, options: CacheGetOrSetOptions): Promisable + + /** + * Runs `compute` and stores its output. Tag state captured before it runs + * lets a revalidation that lands during it still invalidate the entry. + */ + protected abstract fill(encodedKey: string, compute: () => Promise, options: CacheGetOrSetOptions): Promise + + /** + * Restores the entry a backend stored as an envelope. + */ + protected decode(envelope: CacheEnvelope): CacheEntry { + return { + output: this.serializer.deserialize(envelope.output), + tags: envelope.tags, + expiresAt: envelope.expiresAt, + evictAt: envelope.evictAt, + } + } + + /** + * Builds the envelope storing `output`, with the entry to return and how + * long to retain it in milliseconds, `undefined` when it never expires. + */ + protected encode(output: unknown, options: CacheGetOrSetOptions): { envelope: CacheEnvelope, entry: CacheEntry, retention: number | undefined } { + const tags = options.tags?.length ? options.tags : undefined + const { expiresAt, evictAt, retention } = resolveCacheExpiry(options) + const { json, meta } = this.serializer.serialize(output) + + return { + envelope: { output: { json, meta }, tags, expiresAt, evictAt }, + entry: { output, tags, expiresAt, evictAt }, + retention, + } + } + + /** + * Runs `fn` under the key's lock, or `onTimeout` once waiting for it timed out. + */ + private async lock(encodedKey: string, fn: (waited: boolean) => Promise, onTimeout: () => Promisable): Promise { + try { + return await this.locker.lock(encodedKey, ({ waited }) => fn(waited)) + } + catch (error) { + if (error instanceof LockTimeoutError) { + return onTimeout() + } + + throw error + } + } +} diff --git a/packages/cache/src/adapters/base-redis.ts b/packages/cache/src/adapters/base-redis.ts new file mode 100644 index 000000000..f5b22d179 --- /dev/null +++ b/packages/cache/src/adapters/base-redis.ts @@ -0,0 +1,127 @@ +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions } from '../types' +import type { BaseKeyValueCacheStoreOptions, CacheEnvelope } from './base-key-value' +import { parseEmptyableJSON, stringifyJSON } from '@orpc/shared' +import { BaseKeyValueCacheStore } from './base-key-value' + +interface BaseRedisCacheStoreEnvelope extends CacheEnvelope { + /** + * The version each tag had when the fill started, index-aligned with `tags`. + */ + tagVersions?: readonly number[] | undefined +} + +/** + * Options shared by every Redis-backed cache store adapter. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export interface BaseRedisCacheStoreOptions extends BaseKeyValueCacheStoreOptions { + /** + * The prefix to use for Redis keys. + * + * @default '' + */ + prefix?: string +} + +/** + * Base class for Redis-backed cache store adapters. Entries are JSON strings + * retained for `ttl + swr`, and each tag is a counter that revalidation + * increments; an entry records the counters of its tags when its fill + * starts and is dropped once one moved. Only single-key commands are used, + * so it works on Redis Cluster, and every adapter built on it shares the + * same key and entry format. Fills are coalesced through the `locker`, in + * the process by default; tag counters have no expiry since expiring one + * would resurrect stale entries. + * + * Extend it and implement the abstract methods to support another Redis client. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export abstract class BaseRedisCacheStore extends BaseKeyValueCacheStore { + private readonly entryPrefix: string + private readonly tagPrefix: string + + constructor(options: BaseRedisCacheStoreOptions = {}) { + super(options) + const prefix = options.prefix ?? '' + this.entryPrefix = `${prefix}e:` + this.tagPrefix = `${prefix}t:` + } + + /** + * Reads a string key (`GET key`), `null` when it is missing. A client that + * parses JSON replies may resolve with the parsed value. + */ + protected abstract get(key: string): Promise + + /** + * Reads several string keys, `null` for each missing one (`MGET key [key ...]`). + * Where a cluster forbids that, one `GET` per key sent together works too. + */ + protected abstract getMany(keys: string[]): Promise + + /** + * Writes a string key, expiring after `px` milliseconds when given + * (`SET key value [PX px]`). + */ + protected abstract set(key: string, value: string, px: number | undefined): Promise + + /** + * Increments a counter key (`INCR key`). + */ + protected abstract increment(key: string): Promise + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await Promise.all(tags.map(tag => this.increment(this.tagPrefix + tag))) + } + + protected async read(encodedKey: string, options: CacheGetOrSetOptions): Promise { + const expectedTags = options.tags ?? [] + const [raw, expectedVersions] = await Promise.all([this.get(this.entryPrefix + encodedKey), this.versions(expectedTags)]) + const envelope = (typeof raw === 'string' ? parseEmptyableJSON(raw) : raw) as BaseRedisCacheStoreEnvelope | null | undefined + + if (envelope == null) { + return undefined + } + + if (envelope.tags?.length) { + const { tags } = envelope + const versions = tags.length === expectedTags.length && tags.every((tag, index) => tag === expectedTags[index]) + ? expectedVersions + : await this.versions(tags) + + const stored = envelope.tagVersions ?? [] + + if (tags.some((_, index) => versions[index] !== (stored[index] ?? 0))) { + return undefined + } + } + + return this.decode(envelope) + } + + protected async fill(encodedKey: string, compute: () => Promise, options: CacheGetOrSetOptions): Promise { + const tagVersions = options.tags?.length ? await this.versions(options.tags) : undefined + const output = await compute() + const { envelope, entry, retention } = this.encode(output, options) + + await this.set( + this.entryPrefix + encodedKey, + stringifyJSON({ ...envelope, tagVersions } satisfies BaseRedisCacheStoreEnvelope), + retention, + ) + + return entry + } + + private async versions(tags: readonly string[]): Promise { + if (!tags.length) { + return [] + } + + const raw = await this.getMany(tags.map(tag => this.tagPrefix + tag)) + + return raw.map(version => Number(version ?? 0)) + } +} diff --git a/packages/cache/src/adapters/cacheable.test.ts b/packages/cache/src/adapters/cacheable.test.ts new file mode 100644 index 000000000..efd2235a1 --- /dev/null +++ b/packages/cache/src/adapters/cacheable.test.ts @@ -0,0 +1,61 @@ +import { RPCJsonSerializer } from '@orpc/client' +import { Cacheable, Keyv } from 'cacheable' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { CacheableCacheStore } from './cacheable' + +describe('cacheableCacheStore', () => { + describe('against the memory primary', () => { + describeCacheStoreContract(() => new CacheableCacheStore(new Cacheable())) + }) + + describe('against a primary with a Keyv secondary', () => { + describeCacheStoreContract(() => new CacheableCacheStore(new Cacheable({ secondary: new Keyv() }))) + }) + + it('enables the tag service, and revalidates through it', async () => { + const cacheable = new Cacheable() + expect(cacheable.tags.enabled).toBe(false) + const invalidateTags = vi.spyOn(cacheable.tags, 'invalidateTags') + const store = new CacheableCacheStore(cacheable) + + expect(cacheable.tags.enabled).toBe(true) + + await store.revalidate({ tags: ['a', 'b'] }) + expect(invalidateTags).toHaveBeenCalledWith(['a', 'b']) + }) + + it('maps ttl + swr to a retention in milliseconds, and passes tags on', async () => { + const cacheable = new Cacheable() + const set = vi.spyOn(cacheable, 'set') + const store = new CacheableCacheStore(cacheable) + + await store.getOrSet('k', async () => 'v', { tags: ['t'], ttl: 1000, swr: 1000 }) + expect(set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'] }), { tags: ['t'], ttl: 2000 }) + + await store.getOrSet('forever', async () => 'v') + expect(set).toHaveBeenCalledWith('forever', expect.objectContaining({ tags: undefined }), {}) + }) + + it('evicts defensively when the backend still holds an entry past evictAt', async () => { + const cacheable = new Cacheable() + const store = new CacheableCacheStore(cacheable) + await store.getOrSet('k', async () => 'v') + + vi.spyOn(cacheable, 'get').mockResolvedValueOnce({ output: { json: 'v' }, expiresAt: 1, evictAt: 1 }) + + await expect(store.getOrSet('k', async () => 'refilled')).resolves.toMatchObject({ output: 'refilled' }) + }) + + it('supports a custom serializer', async () => { + const serializer = new RPCJsonSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') + const store = new CacheableCacheStore(new Cacheable(), { serializer }) + + await store.getOrSet('k', async () => ({ date: new Date(1) })) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: { date: new Date(1) } }) + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() + }) +}) diff --git a/packages/cache/src/adapters/cacheable.ts b/packages/cache/src/adapters/cacheable.ts new file mode 100644 index 000000000..27d431a00 --- /dev/null +++ b/packages/cache/src/adapters/cacheable.ts @@ -0,0 +1,47 @@ +import type { Cacheable } from 'cacheable' +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions } from '../types' +import type { BaseKeyValueCacheStoreOptions, CacheEnvelope } from './base-key-value' +import { BaseKeyValueCacheStore } from './base-key-value' + +export type CacheableCacheStoreOptions = BaseKeyValueCacheStoreOptions + +/** + * Cache store adapter for Cacheable, so its primary and secondary stores back + * the cache, such as a memory tier in front of any Keyv store. Tags are + * invalidated natively through its tag service, which the store enables, and + * entries are retained for `ttl + swr`; one without a `ttl` follows the + * instance's own default. A revalidation landing while a fill runs is not + * detected, since Cacheable records tag versions when the entry is written. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class CacheableCacheStore extends BaseKeyValueCacheStore { + constructor( + private readonly cacheable: Cacheable, + options: CacheableCacheStoreOptions = {}, + ) { + super(options) + cacheable.tags.enabled = true + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await this.cacheable.tags.invalidateTags([...tags]) + } + + protected async read(encodedKey: string): Promise { + const envelope = await this.cacheable.get(encodedKey) + + return envelope === undefined ? undefined : this.decode(envelope) + } + + protected async fill(encodedKey: string, compute: () => Promise, options: CacheGetOrSetOptions): Promise { + const { envelope, entry, retention } = this.encode(await compute(), options) + + await this.cacheable.set(encodedKey, envelope, { + ...(envelope.tags ? { tags: [...envelope.tags] } : {}), + ...(retention !== undefined ? { ttl: retention } : {}), + }) + + return entry + } +} diff --git a/packages/cache/src/adapters/memory.test.ts b/packages/cache/src/adapters/memory.test.ts new file mode 100644 index 000000000..a34cc9be8 --- /dev/null +++ b/packages/cache/src/adapters/memory.test.ts @@ -0,0 +1,242 @@ +import { RPCJsonSerializer } from '@orpc/client' +import { MemoryLocker } from '@orpc/experimental-lock/memory' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { MemoryCacheStore } from './memory' + +describe('memoryCacheStore', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describeCacheStoreContract(() => new MemoryCacheStore()) + + it('encodes structurally equal non-string keys to the same entry', async () => { + const store = new MemoryCacheStore() + + await store.getOrSet([['planet', 'find'], { b: 2, a: 1 }], async () => 'v') + + await expect(store.getOrSet([['planet', 'find'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.getOrSet([['planet', 'find'], { a: 1, b: 3 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) + await expect(store.getOrSet([['planet', 'list'], { a: 1, b: 2 }], async () => 'other')).resolves.toMatchObject({ output: 'other' }) + }) + + it('encodes complex key values, ignoring unsupported ones like blobs', async () => { + const store = new MemoryCacheStore() + + await store.getOrSet({ date: new Date(1), big: 1n }, async () => 'v') + await expect(store.getOrSet({ big: 1n, date: new Date(1) }, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(store.getOrSet({ big: 2n, date: new Date(1) }, async () => 'other')).resolves.toMatchObject({ output: 'other' }) + + await store.getOrSet({ file: new Blob(['a']), id: 1 }, async () => 'blobbed') + await expect(store.getOrSet({ file: new Blob(['b']), id: 1 }, async () => 'other')).resolves.toMatchObject({ output: 'blobbed' }) + }) + + it('supports a custom key serializer', async () => { + const serializer = new RPCJsonSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const store = new MemoryCacheStore({ serializer }) + + await store.getOrSet({ id: 1 }, async () => 'v') + + await expect(store.getOrSet({ id: 1 }, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + expect(serializeSpy).toHaveBeenCalled() + }) + + it('returns fresh entries with a future expiresAt, then fills again at ttl without swr', async () => { + const store = new MemoryCacheStore() + + await expect(store.getOrSet('k', async () => 'v', { ttl: 1000 })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1000, evictAt: 1000 }) + + vi.setSystemTime(999) + await expect(store.getOrSet('k', async () => 'other', { ttl: 1000 })).resolves.toMatchObject({ output: 'v' }) + + vi.setSystemTime(1000) + await expect(store.getOrSet('k', async () => 'other', { ttl: 1000 })).resolves.toEqual({ output: 'other', tags: undefined, expiresAt: 2000, evictAt: 2000 }) + }) + + it('serves stale entries within swr while one caller refreshes them in the background', async () => { + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'v', { ttl: 1000, swr: 1000 }) + + vi.setSystemTime(1200) // past ttl, within swr + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const waitUntil = vi.fn() + + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1000, evictAt: 2000 }) + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1000, evictAt: 2000 }) + expect(waitUntil).toHaveBeenCalledTimes(2) + + finish('fresh') + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(fill).toHaveBeenCalledTimes(1) // the second stale hit found the refreshed entry + + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000 })).resolves.toEqual({ output: 'fresh', tags: undefined, expiresAt: 2200, evictAt: 3200 }) + }) + + it('leaves a failed refresh to waitUntil and keeps serving the stale entry', async () => { + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'v', { ttl: 1000, swr: 1000 }) + + vi.setSystemTime(1200) + const waitUntil = vi.fn() + const fill = vi.fn(async () => { + throw new Error('handler down') + }) + + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil })).resolves.toMatchObject({ output: 'v' }) + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') + + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil })).resolves.toMatchObject({ output: 'v' }) + await expect(waitUntil.mock.calls[1]![0]).rejects.toThrow('handler down') + expect(fill).toHaveBeenCalledTimes(2) + }) + + it('lets a waiting refresh fill when the first one failed', async () => { + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'v', { ttl: 1000, swr: 1000 }) + + vi.setSystemTime(1200) + let fail!: (error: Error) => void + const fill = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockResolvedValue('fresh') + const waitUntil = vi.fn() + + await store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil }) + await store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil }) + fail(new Error('handler down')) + + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') + await waitUntil.mock.calls[1]![0] + expect(fill).toHaveBeenCalledTimes(2) + + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000 })).resolves.toMatchObject({ output: 'fresh' }) + }) + + it('drops output computed before a revalidation that landed during its fill', async () => { + const store = new MemoryCacheStore() + let finish!: (output: string) => void + let started!: () => void + const filling = new Promise((resolve) => { + started = resolve + }) + + const first = store.getOrSet('k', () => { + started() + return new Promise((resolve) => { + finish = resolve + }) + }, { tags: ['t'] }) + await filling + await store.revalidate({ tags: ['t'] }) + finish('outdated') + + await expect(first).resolves.toMatchObject({ output: 'outdated' }) + await expect(store.getOrSet('k', async () => 'fresh', { tags: ['t'] })).resolves.toMatchObject({ output: 'fresh' }) + }) + + it('drops a refresh computed before a revalidation that landed during it', async () => { + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'v', { tags: ['t'], ttl: 1000, swr: 10_000 }) + + vi.setSystemTime(1500) + let finish!: (output: string) => void + const waitUntil = vi.fn() + + await store.getOrSet('k', () => new Promise((resolve) => { + finish = resolve + }), { tags: ['t'], ttl: 1000, swr: 10_000, waitUntil }) + await store.revalidate({ tags: ['t'] }) + finish('outdated') + await waitUntil.mock.calls[0]![0] + + await expect(store.getOrSet('k', async () => 'fresh', { tags: ['t'] })).resolves.toMatchObject({ output: 'fresh' }) + }) + + it('lets a waiter fill on its own once waiting for the lock times out, and leaves a timed-out refresh to the holder', async () => { + const store = new MemoryCacheStore({ locker: new MemoryLocker({ timeout: 50 }) }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const fill = vi.fn(async () => { + await held + return 'held' + }) + + const holder = store.getOrSet('k', fill) + await expect(store.getOrSet('k', async () => 'waiter')).resolves.toMatchObject({ output: 'waiter' }) + release() + await expect(holder).resolves.toMatchObject({ output: 'held' }) + + await store.getOrSet('stale', async () => 'v', { ttl: 1000, swr: 10_000 }) + vi.setSystemTime(2000) + const refreshFill = vi.fn(() => new Promise(() => {})) + const waitUntil = vi.fn() + await store.getOrSet('stale', refreshFill, { ttl: 1000, swr: 10_000, waitUntil }) + await store.getOrSet('stale', async () => 'other', { ttl: 1000, swr: 10_000, waitUntil }) + await expect(waitUntil.mock.calls[1]![0]).resolves.toBeUndefined() + expect(refreshFill).toHaveBeenCalledTimes(1) + }) + + it('rethrows other lock errors', async () => { + const locker = { + lock: vi.fn(async () => { + throw new Error('locker down') + }), + } + const store = new MemoryCacheStore({ locker }) + + await expect(store.getOrSet('k', async () => 'v')).rejects.toThrow('locker down') + }) + + it('sweeps expired and revalidated entries on a later write, without reading them', async () => { + const store = new MemoryCacheStore() + const entries = Reflect.get(store, 'entries') as Map + + await store.getOrSet('expiring', async () => 'v', { ttl: 10_000 }) + await store.getOrSet('tagged', async () => 'v', { tags: ['t'] }) + await store.getOrSet('kept', async () => 'v', { ttl: 100_000 }) + + vi.setSystemTime(5000) + await store.getOrSet('before', async () => 'v') + expect([...entries.keys()]).toEqual(['expiring', 'tagged', 'kept', 'before']) + + vi.setSystemTime(10_000) + await store.getOrSet('at', async () => 'v') + expect([...entries.keys()]).toEqual(['tagged', 'kept', 'before', 'at']) + + await store.revalidate({ tags: ['t'] }) + await store.getOrSet('after', async () => 'v') + expect([...entries.keys()]).toEqual(['kept', 'before', 'at', 'after']) + }) + + it('evicts past ttl + swr, and revalidation drops stale entries too', async () => { + const store = new MemoryCacheStore() + + await store.getOrSet('evicted', async () => 'v', { ttl: 1000, swr: 1000 }) + await store.getOrSet('stale', async () => 'v', { tags: ['a'], ttl: 1000, swr: 1000 }) + await store.getOrSet('k', async () => 'old', { tags: ['old'], ttl: 1000 }) + + vi.setSystemTime(1000) // 'k' expired without swr, so it is filled again with new tags + await expect(store.getOrSet('k', async () => 'new', { tags: ['new'] })).resolves.toEqual({ output: 'new', tags: ['new'], expiresAt: undefined }) + + vi.setSystemTime(1200) // 'stale' and 'evicted' are stale + await store.revalidate({ tags: ['a', 'old'] }) + await expect(store.getOrSet('stale', async () => 'refilled', { tags: ['a'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.getOrSet('k', async () => 'refilled', { tags: ['new'] })).resolves.toMatchObject({ output: 'new' }) + + vi.setSystemTime(2000) // past ttl + swr + await expect(store.getOrSet('evicted', async () => 'refilled')).resolves.toMatchObject({ output: 'refilled' }) + }) +}) diff --git a/packages/cache/src/adapters/memory.ts b/packages/cache/src/adapters/memory.ts new file mode 100644 index 000000000..e80fa76b2 --- /dev/null +++ b/packages/cache/src/adapters/memory.ts @@ -0,0 +1,104 @@ +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions } from '../types' +import type { BaseKeyValueCacheStoreOptions } from './base-key-value' +import { resolveCacheExpiry } from '../utils' +import { BaseKeyValueCacheStore } from './base-key-value' + +export type MemoryCacheStoreOptions = BaseKeyValueCacheStoreOptions + +interface MemoryCacheStoreEntry { + output: unknown + /** + * The tags, and the version counter each had when the fill started, index-aligned. + * Both are absent together when the entry has no tags. + */ + tags?: readonly string[] + tagVersions?: readonly 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 dropped when their key is read again, and otherwise + * swept on the next write once an eviction time or a revalidation has passed. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class MemoryCacheStore extends BaseKeyValueCacheStore { + private readonly entries = new Map() + private readonly tagVersions = new Map() + private nextSweepAt = Infinity + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + for (const tag of tags) { + this.tagVersions.set(tag, (this.tagVersions.get(tag) ?? 0) + 1) + } + + this.nextSweepAt = 0 + } + + protected read(encodedKey: string): CacheEntry | undefined { + const entry = this.entries.get(encodedKey) + + if (!entry) { + return undefined + } + + if (this.shouldEvict(entry, Date.now())) { + this.entries.delete(encodedKey) + return undefined + } + + return { + output: entry.output, + tags: entry.tags, + expiresAt: entry.expiresAt, + evictAt: entry.evictAt, + } + } + + protected async fill(encodedKey: string, compute: () => Promise, options: CacheGetOrSetOptions): Promise { + const tags = options.tags?.length ? options.tags : undefined + const tagVersions = tags?.map(tag => this.tagVersions.get(tag) ?? 0) + const output = await compute() + const { expiresAt, evictAt } = resolveCacheExpiry(options) + + this.sweep() + this.entries.set(encodedKey, { output, tags, tagVersions, expiresAt, evictAt }) + + if (evictAt !== undefined) { + this.nextSweepAt = Math.min(this.nextSweepAt, evictAt) + } + + return { output, tags, expiresAt, evictAt } + } + + private shouldEvict(entry: MemoryCacheStoreEntry, now: number): boolean { + return (entry.evictAt !== undefined && now >= entry.evictAt) + || (entry.tags?.some((tag, index) => (this.tagVersions.get(tag) ?? 0) !== entry.tagVersions?.[index]) ?? false) + } + + /** + * Drops every entry due for eviction once the earliest eviction time or a + * revalidation has passed, so entries never read again still leave the store. + */ + private sweep(): void { + const now = Date.now() + + if (now < this.nextSweepAt) { + return + } + + this.nextSweepAt = Infinity + + for (const [encodedKey, entry] of this.entries) { + if (this.shouldEvict(entry, now)) { + this.entries.delete(encodedKey) + } + else if (entry.evictAt !== undefined) { + this.nextSweepAt = Math.min(this.nextSweepAt, entry.evictAt) + } + } + } +} diff --git a/packages/cache/src/adapters/redis.test.ts b/packages/cache/src/adapters/redis.test.ts new file mode 100644 index 000000000..9efcd6436 --- /dev/null +++ b/packages/cache/src/adapters/redis.test.ts @@ -0,0 +1,44 @@ +import { RedisLocker } from '@orpc/experimental-lock/redis' +import { createClient } from 'redis' +import { describeRedisCacheStoreContract } from '../../tests/__shared__/redis-store-contract' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { RedisCacheStore } from './redis' + +const REDIS_URL = process.env.REDIS_URL + +describe.concurrent('redis cache store integration', { + skip: !REDIS_URL, + timeout: 20_000, +}, async () => { + const redis = createClient({ + url: REDIS_URL, + }) + + beforeAll(async () => { + await redis.connect() + }) + + function createTestingStore(options: ConstructorParameters[1] = {}) { + const prefix = options.prefix ?? `orpc-redis-cache-store-${crypto.randomUUID()}:` + return { store: new RedisCacheStore(redis, { ...options, prefix }), prefix } + } + + describeCacheStoreContract(() => createTestingStore().store) + describeRedisCacheStoreContract(createTestingStore, { + exists: key => redis.exists(key), + type: key => redis.type(key), + set: (key, value) => redis.set(key, value), + createLocker: options => new RedisLocker(redis, options), + }) + + it('lazily connects a closed client', async () => { + const lazyRedis = createClient({ url: REDIS_URL }) + const store = new RedisCacheStore(lazyRedis) + + expect(lazyRedis.isOpen).toBe(false) + await expect(store.getOrSet(crypto.randomUUID(), async () => 'v')).resolves.toMatchObject({ output: 'v' }) + expect(lazyRedis.isOpen).toBe(true) + + await lazyRedis.destroy() + }) +}) diff --git a/packages/cache/src/adapters/redis.ts b/packages/cache/src/adapters/redis.ts new file mode 100644 index 000000000..359c4a20c --- /dev/null +++ b/packages/cache/src/adapters/redis.ts @@ -0,0 +1,52 @@ +import type { RedisClientType, RedisClusterType } from 'redis' +import type { BaseRedisCacheStoreOptions } from './base-redis' +import { BaseRedisCacheStore } from './base-redis' + +export type RedisCacheStoreOptions = BaseRedisCacheStoreOptions + +/** + * Cache store adapter for Redis. Works with both standalone and cluster + * clients, connecting a closed client on first use, and shares its key and + * entry format with the other Redis-backed adapters, so any of them can + * serve the same database. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class RedisCacheStore extends BaseRedisCacheStore { + constructor( + private readonly redis: RedisClientType | RedisClusterType, + options: RedisCacheStoreOptions = {}, + ) { + super(options) + } + + protected async get(key: string): Promise { + return (await this.client()).get(key) + } + + protected async getMany(keys: string[]): Promise { + const client = await this.client() + + return Promise.all(keys.map(key => client.get(key))) + } + + protected async set(key: string, value: string, px: number | undefined): Promise { + const client = await this.client() + + return px === undefined + ? client.set(key, value) + : client.set(key, value, { expiration: { type: 'PX', value: px } }) + } + + protected async increment(key: string): Promise { + return (await this.client()).incr(key) + } + + private async client(): Promise { + if (!this.redis.isOpen) { + await this.redis.connect() + } + + return this.redis + } +} diff --git a/packages/cache/src/adapters/upstash.test.ts b/packages/cache/src/adapters/upstash.test.ts new file mode 100644 index 000000000..a08ce7708 --- /dev/null +++ b/packages/cache/src/adapters/upstash.test.ts @@ -0,0 +1,55 @@ +import { UpstashLocker } from '@orpc/experimental-lock/upstash' +import { Redis } from '@upstash/redis' +import { describeRedisCacheStoreContract } from '../../tests/__shared__/redis-store-contract' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { UpstashCacheStore } from './upstash' + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN + +/** + * These tests depend on a real Upstash redis server — make sure to set the + * `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN` envs. + */ +describe.concurrent('upstash cache store integration', { + // TODO: Upstash is not compatible with Node 26 yet — temporarily disable these tests and revisit in the future. + skip: !UPSTASH_REDIS_REST_URL || !UPSTASH_REDIS_REST_TOKEN || process.versions.node.startsWith('26.'), + timeout: 20_000, +}, () => { + const redis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + }) + + function createTestingStore(options: ConstructorParameters[1] = {}, client = redis) { + const prefix = options.prefix ?? `orpc-upstash-cache-store-${crypto.randomUUID()}:` + return { store: new UpstashCacheStore(client, { ...options, prefix }), prefix } + } + + describeCacheStoreContract(() => createTestingStore().store) + describeRedisCacheStoreContract(createTestingStore, { + exists: key => redis.exists(key), + type: key => redis.type(key), + set: (key, value) => redis.set(key, value), + createLocker: options => new UpstashLocker(redis, options), + }) + + it('reads entries when the client does not parse JSON replies', async () => { + const rawRedis = new Redis({ + url: UPSTASH_REDIS_REST_URL, + token: UPSTASH_REDIS_REST_TOKEN, + automaticDeserialization: false, + }) + const { store } = createTestingStore({}, rawRedis) + + await store.getOrSet('k', async () => ({ a: 1 }), { tags: ['t'], ttl: 60_000 }) + + const entry = await store.getOrSet('k', async () => 'other', { tags: ['t'], ttl: 60_000 }) + expect(entry.output).toEqual({ a: 1 }) + expect(entry.tags).toEqual(['t']) + expect(entry.expiresAt).toBeGreaterThan(Date.now()) + + await store.revalidate({ tags: ['t'] }) + await expect(store.getOrSet('k', async () => 'refilled', { tags: ['t'] })).resolves.toMatchObject({ output: 'refilled' }) + }) +}) diff --git a/packages/cache/src/adapters/upstash.ts b/packages/cache/src/adapters/upstash.ts new file mode 100644 index 000000000..b5ae010d3 --- /dev/null +++ b/packages/cache/src/adapters/upstash.ts @@ -0,0 +1,39 @@ +import type { Redis } from '@upstash/redis' +import type { BaseRedisCacheStoreOptions } from './base-redis' +import { BaseRedisCacheStore } from './base-redis' + +export type UpstashCacheStoreOptions = BaseRedisCacheStoreOptions + +/** + * Cache store adapter for Upstash Redis. Shares its key and entry format with + * `RedisCacheStore`, so both can serve the same database. A good fit for + * serverless and edge runtimes. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class UpstashCacheStore extends BaseRedisCacheStore { + constructor( + private readonly redis: Redis, + options: UpstashCacheStoreOptions = {}, + ) { + super(options) + } + + protected get(key: string): Promise { + return this.redis.get(key) + } + + protected getMany(keys: string[]): Promise { + return this.redis.mget(...keys) + } + + protected set(key: string, value: string, px: number | undefined): Promise { + return px === undefined + ? this.redis.set(key, value) + : this.redis.set(key, value, { px }) + } + + protected increment(key: string): Promise { + return this.redis.incr(key) + } +} diff --git a/packages/cache/src/adapters/vercel.test.ts b/packages/cache/src/adapters/vercel.test.ts new file mode 100644 index 000000000..88719fb16 --- /dev/null +++ b/packages/cache/src/adapters/vercel.test.ts @@ -0,0 +1,150 @@ +import type { RuntimeCache } from '@vercel/functions' +import { RPCJsonSerializer } from '@orpc/client' +import { getCache } from '@vercel/functions' +import { describeCacheStoreContract } from '../../tests/__shared__/store-contract' +import { VercelCacheStore } from './vercel' + +describe('vercelCacheStore', () => { + describe('against the in-memory getCache fallback', () => { + function createTestingStore() { + return new VercelCacheStore({ + cache: getCache({ namespace: crypto.randomUUID() }), + }) + } + + describeCacheStoreContract(createTestingStore) + + it('defaults to getCache when no cache is given', async () => { + const store = new VercelCacheStore() + const key = crypto.randomUUID() + + await store.getOrSet(key, async () => 'v') + + await expect(store.getOrSet(key, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + }) + + 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 the retention it passes on', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.getOrSet('k', async () => 'v', { tags: ['t'], ttl: 1000, swr: 1000 }) + + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: ['t'], expiresAt: 1000, evictAt: 2000 }), { 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.getOrSet('k', async () => '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.getOrSet('k', async () => 'v') + + expect(cache.set).toHaveBeenCalledWith('k', expect.objectContaining({ tags: undefined }), {}) + }) + + it('serves stale entries within the swr window, refreshes through waitUntil, and evicts defensively', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + + await store.getOrSet('k', async () => 'v', { ttl: 1000, swr: 1000 }) + + vi.setSystemTime(1200) // past ttl, within swr + const waitUntil = vi.fn() + await expect(store.getOrSet('k', async () => { + throw new Error('handler down') + }, { ttl: 1000, swr: 1000, waitUntil })).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: 1000, evictAt: 2000 }) + await expect(waitUntil.mock.calls[0]![0]).rejects.toThrow('handler down') + + vi.setSystemTime(2000) // past ttl + swr, backend has not evicted yet + await expect(store.getOrSet('k', async () => 'refilled', { ttl: 1000, swr: 1000 })).resolves.toMatchObject({ output: 'refilled' }) + }) + + it('refreshes once for concurrent stale hits, and again when the first refresh failed', async () => { + const cache = createMockedCache() + const store = new VercelCacheStore({ cache }) + await store.getOrSet('k', async () => 'v', { ttl: 1000, swr: 1000 }) + + vi.setSystemTime(1200) + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const waitUntil = vi.fn() + + await store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil }) + await store.getOrSet('k', fill, { ttl: 1000, swr: 1000, waitUntil }) + finish('fresh') + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(fill).toHaveBeenCalledTimes(1) + await expect(store.getOrSet('k', fill, { ttl: 1000, swr: 1000 })).resolves.toMatchObject({ output: 'fresh' }) + + vi.setSystemTime(2400) // stale again + let fail!: (error: Error) => void + const failingFill = vi.fn() + .mockImplementationOnce(() => new Promise((_, reject) => { + fail = reject + })) + .mockResolvedValue('fresher') + const waitUntilAgain = vi.fn() + + await store.getOrSet('k', failingFill, { ttl: 1000, swr: 1000, waitUntil: waitUntilAgain }) + await store.getOrSet('k', failingFill, { ttl: 1000, swr: 1000, waitUntil: waitUntilAgain }) + fail(new Error('handler down')) + + await expect(waitUntilAgain.mock.calls[0]![0]).rejects.toThrow('handler down') + await waitUntilAgain.mock.calls[1]![0] + expect(failingFill).toHaveBeenCalledTimes(2) + await expect(store.getOrSet('k', failingFill, { ttl: 1000, swr: 1000 })).resolves.toMatchObject({ output: 'fresher' }) + }) + + it('supports a custom serializer', async () => { + const cache = createMockedCache() + const serializer = new RPCJsonSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') + const store = new VercelCacheStore({ cache, serializer }) + + await store.getOrSet('k', async () => ({ a: 1 })) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/cache/src/adapters/vercel.ts b/packages/cache/src/adapters/vercel.ts new file mode 100644 index 000000000..1c06e4826 --- /dev/null +++ b/packages/cache/src/adapters/vercel.ts @@ -0,0 +1,53 @@ +import type { RuntimeCache } from '@vercel/functions' +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions } from '../types' +import type { BaseKeyValueCacheStoreOptions, CacheEnvelope } from './base-key-value' +import { getCache } from '@vercel/functions' +import { BaseKeyValueCacheStore } from './base-key-value' + +export interface VercelCacheStoreOptions extends BaseKeyValueCacheStoreOptions { + /** + * The Vercel Runtime Cache to use. + * + * @default getCache() + */ + cache?: RuntimeCache +} + +/** + * Cache store adapter for the Vercel Runtime Cache. Tags are expired + * natively via `expireTag`, and entries are retained for `ttl + swr`. + * Outside Vercel, the default `getCache()` falls back to an in-memory + * cache. A revalidation landing while a fill runs is not detected, since + * the Runtime Cache has no atomic primitive. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class VercelCacheStore extends BaseKeyValueCacheStore { + private readonly cache: RuntimeCache + + constructor(options: VercelCacheStoreOptions = {}) { + super(options) + this.cache = options.cache ?? getCache() + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + await this.cache.expireTag([...tags]) + } + + protected async read(encodedKey: string): Promise { + const envelope = await this.cache.get(encodedKey) as CacheEnvelope | null | undefined + + return envelope == null ? undefined : this.decode(envelope) + } + + protected async fill(encodedKey: string, compute: () => Promise, options: CacheGetOrSetOptions): Promise { + const { envelope, entry, retention } = this.encode(await compute(), options) + + await this.cache.set(encodedKey, envelope, { + ...(envelope.tags ? { tags: [...envelope.tags] } : {}), + ...(retention !== undefined ? { ttl: Math.ceil(retention / 1000) } : {}), + }) + + return entry + } +} diff --git a/packages/cache/src/handler-plugin.test.ts b/packages/cache/src/handler-plugin.test.ts new file mode 100644 index 000000000..8c3186c81 --- /dev/null +++ b/packages/cache/src/handler-plugin.test.ts @@ -0,0 +1,253 @@ +import type { AnyProcedure } from '@orpc/server' +import type { StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardHeaders } from '@standard-server/core' +import type { CacheHandlerPluginContext, CacheHandlerPluginHeader } from './handler-plugin' +import type { CacheContext } from './types' +import { call, ORPCError, os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import { decodeCacheTagHeader, toArray } from '@orpc/shared' +import { MemoryCacheStore } from './adapters/memory' +import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL, CacheHandlerPlugin } from './handler-plugin' +import { cache, revalidate } from './middleware' + +type RecordedChecks = Exclude +type PartialCheck = Partial & { tags: readonly string[] } + +/** + * Sets response headers from inside the cache plugin's interceptor, standing + * in for a handler or inner plugin that set its own. + */ +function presetHeadersPlugin(preset: StandardHeaders): StandardHandlerPlugin { + return { + name: '~preset-headers', + init: options => ({ + ...options, + interceptors: [...toArray(options.interceptors), async (interceptorOptions) => { + const response = await interceptorOptions.next() + return { ...response, headers: { ...response.headers, ...preset } } + }], + }), + } +} + +function createTestingHandler(headers?: readonly CacheHandlerPluginHeader[], preset?: StandardHeaders) { + const handlerFn = vi.fn() + const handler = new RPCHandler(os.handler(handlerFn), { + allowMethods: ['GET', 'POST'], + plugins: [ + new CacheHandlerPlugin({ headers: headers ?? [] }), + // Registered last so its interceptor runs innermost, before the plugin looks. + ...preset ? [presetHeadersPlugin(preset)] : [], + ], + }) + + return { + handlerFn, + + /** + * Records checks against the called procedure and path, as the + * middlewares do, then runs `then` inside the same handler call. Each + * check may override either field to simulate a nested call. + */ + record(checks: { caches?: PartialCheck[], revalidations?: PartialCheck[] }, then?: () => void) { + handlerFn.mockImplementationOnce(({ context, path, procedure }) => { + const recorded: RecordedChecks = context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + recorded.caches.push(...toArray(checks.caches).map(check => ({ path, procedure, ...check }))) + recorded.revalidations.push(...toArray(checks.revalidations).map(check => ({ path, procedure, ...check }))) + then?.() + }) + }, + + async handle(init?: RequestInit): Promise { + const { response } = await handler.handle(new Request('http://localhost:3000', init)) + return response! + }, + } +} + +const POST = { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, +} satisfies RequestInit + +describe('cacheHandlerPlugin', () => { + it('does nothing until headers are configured', async () => { + const { handlerFn, handle } = createTestingHandler() + handlerFn.mockImplementationOnce(({ context }) => { + expect(context[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]).toBeUndefined() + }) + + const response = await handle() + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(response.headers.get('orpc-cache-tag')).toBe(null) + }) + + describe('orpc-cache-tag & orpc-cache-tag-invalidation', () => { + const headers = ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] as const + + it('reflects the first check of each kind belonging to the called procedure', async () => { + const { record, handle } = createTestingHandler(headers) + record({ + caches: [{ tags: ['planets', 'planet:1'] }, { tags: ['ignored'] }], + revalidations: [{ tags: ['revalidated'] }, { tags: ['ignored'] }], + }) + + const response = await handle() + + expect(response.headers.get('orpc-cache-tag')).toBe('planets,planet:1') + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe('revalidated') + }) + + it('sets each header only when its own kind of check ran', async () => { + const cacheOnly = createTestingHandler(headers) + cacheOnly.record({ caches: [{ tags: ['a'] }] }) + const first = await cacheOnly.handle() + + expect(first.headers.get('orpc-cache-tag')).toBe('a') + expect(first.headers.get('orpc-cache-tag-invalidation')).toBe(null) + + const revalidationOnly = createTestingHandler(headers) + revalidationOnly.record({ revalidations: [{ tags: ['b'] }] }) + const second = await revalidationOnly.handle() + + expect(second.headers.get('orpc-cache-tag')).toBe(null) + expect(second.headers.get('orpc-cache-tag-invalidation')).toBe('b') + }) + + it('ignores checks recorded for other procedures or paths', async () => { + const other = os.handler(() => 'other') + const { record, handle } = createTestingHandler(headers) + record({ + caches: [ + { procedure: other as AnyProcedure, tags: ['other-procedure'] }, + { path: ['nested'], tags: ['other-path'] }, + ], + revalidations: [{ procedure: other as AnyProcedure, tags: ['other-procedure'] }], + }) + + const response = await handle() + + expect(response.headers.get('orpc-cache-tag')).toBe(null) + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe(null) + }) + + it('skips headers when no check ran, or its tags are empty', async () => { + const noChecks = await createTestingHandler(headers).handle() + + expect(noChecks.headers.get('orpc-cache-tag')).toBe(null) + expect(noChecks.headers.get('orpc-cache-tag-invalidation')).toBe(null) + + const emptyTags = createTestingHandler(headers) + emptyTags.record({ caches: [{ tags: [] }], revalidations: [{ tags: [] }] }) + const response = await emptyTags.handle() + + expect(response.headers.get('orpc-cache-tag')).toBe(null) + expect(response.headers.get('orpc-cache-tag-invalidation')).toBe(null) + }) + + it('skips headers on error responses', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets'] }] }, () => { + throw new ORPCError('INTERNAL_SERVER_ERROR') + }) + + const response = await handle() + + expect(response.status).toBe(500) + expect(response.headers.get('orpc-cache-tag')).toBe(null) + }) + + it('percent-encodes tags containing special characters', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['a,b', 'tiếng việt'] }] }) + + const header = (await handle()).headers.get('orpc-cache-tag')! + + expect(header).toBe('a%2Cb,ti%E1%BA%BFng%20vi%E1%BB%87t') + expect(decodeCacheTagHeader(header)).toEqual(['a,b', 'tiếng việt']) + }) + }) + + describe('cache-control & cache-tag', () => { + const headers = ['cache-control', 'cache-tag'] as const + + it('reflects the root check, leaving unconfigured headers alone', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets', 'a,b'], ttl: 2000, swr: 1000 }] }) + + const response = await handle() + + expect(response.headers.get('orpc-cache-tag')).toBe(null) // only configured headers are set + expect(response.headers.get('cache-tag')).toBe('planets,a%2Cb') + expect(response.headers.get('cache-control')).toBe('public, max-age=2, stale-while-revalidate=1') + }) + + it('holds entries without a ttl for a year, and skips Cache-Tag without tags', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: [] }] }) + + const response = await handle() + + expect(response.headers.get('cache-tag')).toBe(null) + expect(response.headers.get('cache-control')).toBe('public, max-age=31536000') + }) + + it('sets its headers over ones already on the response', async () => { + const { record, handle } = createTestingHandler(headers, { 'cache-control': 'private, no-store', 'cache-tag': 'stale' }) + record({ caches: [{ tags: ['planets'], ttl: 2000 }] }) + + const response = await handle() + + expect(response.headers.get('cache-control')).toBe('public, max-age=2') + expect(response.headers.get('cache-tag')).toBe('planets') + }) + + it('reflects the root check whatever the request method', async () => { + const { record, handle } = createTestingHandler(headers) + record({ caches: [{ tags: ['planets'], ttl: 2000 }] }) + + const response = await handle(POST) + + expect(response.headers.get('cache-tag')).toBe('planets') + expect(response.headers.get('cache-control')).toBe('public, max-age=2') + }) + + it('skips HTTP caching headers without a root cache check', async () => { + const { record, handle } = createTestingHandler(headers) + record({ revalidations: [{ tags: ['planets'] }] }) + + const response = await handle() + + expect(response.headers.get('cache-tag')).toBe(null) + expect(response.headers.get('cache-control')).toBe(null) + }) + }) + + it('only reflects the tags of the procedure the client called in nested calls', async () => { + const inner = os + .$context() + .use(cache({ key: 'inner', tags: ['inner-tag'] })) + .use(revalidate({ tags: ['inner-revalidated'] })) + .handler(() => 'inner') + + const outer = os + .$context() + .use(cache({ key: 'outer', tags: ['outer-tag'] })) + .use(revalidate({ tags: ['outer-revalidated'] })) + .handler(async ({ context }) => `outer:${await call(inner, undefined, { context })}`) + + const handler = new RPCHandler({ outer, inner }, { + allowMethods: ['GET'], + plugins: [new CacheHandlerPlugin({ headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] })], + }) + + const { response } = await handler.handle(new Request('http://localhost:3000/outer'), { + context: { 'cache/store': new MemoryCacheStore() }, + }) + + expect(response!.headers.get('orpc-cache-tag')).toBe('outer-tag') + expect(response!.headers.get('orpc-cache-tag-invalidation')).toBe('outer-revalidated') + }) +}) diff --git a/packages/cache/src/handler-plugin.ts b/packages/cache/src/handler-plugin.ts new file mode 100644 index 000000000..670128f27 --- /dev/null +++ b/packages/cache/src/handler-plugin.ts @@ -0,0 +1,150 @@ +import type { AnyProcedure, Context } from '@orpc/server' +import type { StandardHandlerInterceptor, StandardHandlerOptions, StandardHandlerPlugin } from '@orpc/server/standard' +import type { StandardHeaders } from '@standard-server/core' +import { encodeCacheTagHeader, toArray } from '@orpc/shared' + +export const CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL: unique symbol = Symbol.for('ORPC_CACHE_HANDLER_PLUGIN_CONTEXT') + +/** + * A cache lookup performed during a request. `ttl` carries the remaining + * freshness in milliseconds on hits and the resolved fresh lifetime on stores. + */ +export interface CacheHandlerPluginLookup { + procedure: AnyProcedure + path: readonly string[] + tags?: readonly string[] | undefined + ttl?: number | undefined + swr?: number | undefined +} + +/** + * A tag revalidation committed during a request. + */ +export interface CacheHandlerPluginRevalidation { + procedure: AnyProcedure + path: readonly string[] + tags: readonly string[] +} + +export interface CacheHandlerPluginContext { + [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]?: { + /** + * The cache lookups performed during this request, in the order they ran. + */ + caches: CacheHandlerPluginLookup[] + + /** + * The tag revalidations committed during this request, in the order they ran. + */ + revalidations: CacheHandlerPluginRevalidation[] + } +} + +/** + * 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 + = | 'orpc-cache-tag' + | 'orpc-cache-tag-invalidation' + | 'cache-control' + | 'cache-tag' + +export interface CacheHandlerPluginOptions<_T extends Context> { + /** + * The response headers to set from the root procedure's cache activity; + * only listed headers are set. `orpc-cache-tag` carries the tags the + * 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. + */ + 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 { procedure, path } = interceptorOptions + const isRoot = (check: CacheHandlerPluginRevalidation | CacheHandlerPluginLookup) => + check.procedure === procedure && check.path.length === path.length && check.path.every((segment, index) => segment === path[index]) + + const rootCache = pluginContext.caches.find(isRoot) + const rootRevalidation = pluginContext.revalidations.find(isRoot) + + if (rootCache === undefined && rootRevalidation === undefined) { + return response + } + + const headers: StandardHeaders = {} + const set = (name: CacheHandlerPluginHeader, value: string | undefined) => { + if (value !== undefined && this.headers.has(name)) { + headers[name] = value + } + } + + const cacheTag = rootCache?.tags?.length ? encodeCacheTagHeader(rootCache.tags) : undefined + set('orpc-cache-tag', cacheTag) + set('cache-tag', cacheTag) + set('orpc-cache-tag-invalidation', rootRevalidation?.tags.length ? encodeCacheTagHeader(rootRevalidation.tags) : undefined) + + if (rootCache !== undefined && this.headers.has('cache-control')) { + /** + * `max-age` rather than `s-maxage`, which carries `proxy-revalidate` + * semantics ([RFC 9111](https://www.rfc-editor.org/rfc/rfc9111#section-5.2.2.10)) + * and so forbids the stale reuse `stale-while-revalidate` grants. + * Entries without a ttl stay valid until revalidated, so caches hold + * them for a year and rely on tag purges. + */ + const maxAge = rootCache.ttl === undefined ? 31536000 : Math.floor(rootCache.ttl / 1000) + const staleWhileRevalidate = Math.floor((rootCache.swr ?? 0) / 1000) + headers['cache-control'] = `public, max-age=${maxAge}${staleWhileRevalidate ? `, stale-while-revalidate=${staleWhileRevalidate}` : ''}` + } + + return { + ...response, + headers: { ...response.headers, ...headers }, + } + } + + return { + ...options, + interceptors: [ + ...toArray(options.interceptors), + interceptor, + ], + } + } +} diff --git a/packages/cache/src/index.test.ts b/packages/cache/src/index.test.ts new file mode 100644 index 000000000..8f8d3f604 --- /dev/null +++ b/packages/cache/src/index.test.ts @@ -0,0 +1,9 @@ +it('exports the plugin, middleware factories, tiered store, and key helper', async () => { + await expect(import('./index')).resolves.toMatchObject({ + CacheHandlerPlugin: expect.any(Function), + TieredCacheStore: expect.any(Function), + cache: expect.any(Function), + revalidate: expect.any(Function), + encodeCacheKey: expect.any(Function), + }) +}) diff --git a/packages/cache/src/index.ts b/packages/cache/src/index.ts new file mode 100644 index 000000000..a6584672d --- /dev/null +++ b/packages/cache/src/index.ts @@ -0,0 +1,5 @@ +export * from './handler-plugin' +export * from './middleware' +export * from './tiered' +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..767799ad7 --- /dev/null +++ b/packages/cache/src/middleware.test-d.ts @@ -0,0 +1,130 @@ +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/store': 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/store']).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/store']).toEqualTypeOf() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context.db).toBeString() + expectTypeOf(input.id).toBeNumber() + + return 'ok' + }) + }) + + it('key is optional and accepts any 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/store': CacheStore }>() + .input(type<{ id: number }>()) + .use( + revalidate({ + tags: async ({ context }, input) => { + expectTypeOf(input.id).toBeNumber() + expectTypeOf(context.userId).toBeString() + expectTypeOf(context['cache/store']).toEqualTypeOf() + + return [`planet:${input.id}`] + }, + }), + ) + .handler(({ context, input }) => { + expectTypeOf(context['cache/store']).toEqualTypeOf() + expectTypeOf(context.userId).toBeString() + expectTypeOf(input.id).toBeNumber() + + return 'ok' + }) + }) + + it('requires a non-empty tag list, but a function may decline', () => { + const base = os.$context() + + void base.use(revalidate({ tags: ['planets', 'planet:1'] })) + void base.use(revalidate({ tags: () => ['planets'] })) + void base.use(revalidate({ tags: () => undefined })) + void base.use(revalidate({ tags: () => null })) + + // @ts-expect-error - tags is required + void base.use(revalidate({})) + + // @ts-expect-error - tags must not be empty + void base.use(revalidate({ tags: [] })) + + // @ts-expect-error - tags must be a list + void base.use(revalidate({ tags: 'planets' })) + }) + + it('requires the cache store to be declared in the initial context', () => { + void os.$context().use(revalidate({ tags: ['t'] })) + + // @ts-expect-error - initial context must provide the cache store + void os.use(revalidate({ tags: ['t'] })) + }) +}) diff --git a/packages/cache/src/middleware.test.ts b/packages/cache/src/middleware.test.ts new file mode 100644 index 000000000..e05e15afa --- /dev/null +++ b/packages/cache/src/middleware.test.ts @@ -0,0 +1,365 @@ +import type { CacheHandlerPluginContext } from './handler-plugin' +import type { CacheContext, CacheEntry, CacheStore } from './types' +import { call, os, type } from '@orpc/server' +import { MemoryCacheStore } from './adapters/memory' +import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' +import { cache, revalidate } from './middleware' + +/** + * A store that serves `entry` when given, and otherwise fills like a miss. + */ +function createStore(entry?: CacheEntry) { + return { + getOrSet: vi.fn(async (_key, fill, options) => entry ?? { + output: await fill(), + tags: options?.tags, + expiresAt: options?.ttl !== undefined ? Date.now() + options.ttl : undefined, + evictAt: options?.ttl !== undefined ? Date.now() + options.ttl + (options.swr ?? 0) : undefined, + }), + revalidate: vi.fn().mockResolvedValue(undefined), + } +} + +describe('cache', () => { + it('fills through the store on miss and returns the output', async () => { + const store = createStore() + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t1', 't2'], ttl: 60_000, swr: 30_000 })) + .handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe('fresh') + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.getOrSet).toHaveBeenCalledWith('k', expect.any(Function), { tags: ['t1', 't2'], ttl: 60_000, swr: 30_000, waitUntil: undefined }) + }) + + 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': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(procedure, { id: 1 }, { context: { 'cache/store': store }, path: ['user', 'find'] }) + + const keys = store.getOrSet.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('uses a provided key as-is, whatever its type', 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': store }, path: ['planet', 'find'] }) + await call(material, { id: 1, page: 2 }, { context: { 'cache/store': store }, path: ['planet', 'find'] }) + await call(verbatim, undefined, { context: { 'cache/store': store } }) + + const keys = store.getOrSet.mock.calls.map(([key]) => key) + expect(keys[0]).toEqual({ id: 1 }) // the resolved material, not combined with the path + expect(keys[0]).toEqual(keys[1]) // same material despite different inputs + expect(keys[2]).toBe('k') + }) + + 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': store } }) + await call(procedure, { id: 1, page: 2 } as any, { context: { 'cache/store': store } }) + + // The middleware only validated `id` at its position, but the key still + // covers the full input, so different pages never share an entry. + const keys = store.getOrSet.mock.calls.map(([key]) => key) + expect(keys[0]).not.toEqual(keys[1]) + }) + }) + + it.each<[string, CacheEntry, unknown]>([ + ['a fresh entry', { output: 'cached', tags: ['t'], expiresAt: Date.now() + 60_000 }, 'cached'], + ['an entry that never expires', { output: 'cached', tags: [] }, 'cached'], + ['a cached undefined output', { output: undefined, tags: [] }, undefined], + ])('serves %s without running the handler', async (_, entry, expected) => { + const store = createStore(entry) + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe(expected) + + 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(60_000) + const swrFn = vi.fn().mockResolvedValueOnce(30_000) + 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<{ id: number }>()).use(mw).handler(() => 'fresh') + + await expect( + call(procedure, { id: 1 }, { context: { 'cache/store': store } }), + ).resolves.toBe('fresh') + + for (const fn of [keyFn, tagsFn, ttlFn, swrFn, enabledFn]) { + expect(fn).toHaveBeenCalledTimes(1) + expect(fn).toHaveBeenCalledWith(expect.objectContaining({ context: expect.any(Object) }), { id: 1 }) + } + expect(store.getOrSet).toHaveBeenCalledWith('k', expect.any(Function), { tags: ['t'], ttl: 60_000, swr: 30_000, waitUntil: undefined }) + }) + + it('skips the store when enabled resolves to false', async () => { + const store = createStore({ output: 'cached', tags: [] }) + const handlerFn = vi.fn().mockReturnValue('fresh') + const procedure = os.$context().use(cache({ key: 'k', enabled: false })).handler(handlerFn) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe('fresh') + + expect(handlerFn).toHaveBeenCalledTimes(1) + expect(store.getOrSet).not.toHaveBeenCalled() + }) + + it('hands cache/waitUntil to the store', async () => { + const store = createStore() + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + + await call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) + + expect(store.getOrSet).toHaveBeenCalledWith('k', expect.any(Function), expect.objectContaining({ waitUntil })) + }) + + it('records the entry into the handler plugin context with its remaining ttl and swr', async () => { + const now = Date.now() + const stale = createStore({ output: 'stale', tags: ['stored'], expiresAt: now - 10_000, evictAt: now + 20_000 }) + const fresh = createStore({ output: 'fresh', tags: ['stored'], expiresAt: now + 60_000, evictAt: now + 90_000 }) + const pluginContext: Exclude = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'], swr: 30_000 })) + .handler(() => 'filled') + const context = { [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } + + await call(procedure, undefined, { context: { 'cache/store': stale, ...context }, path: ['__path__'] }) + await call(procedure, undefined, { context: { 'cache/store': fresh, ...context }, path: ['__path__'] }) + await call(procedure, undefined, { context: { 'cache/store': createStore(), ...context }, path: ['__path__'] }) + await call(procedure, undefined, { context: { 'cache/store': createStore({ output: 'fresh', tags: ['stored'], expiresAt: now + 60_000 }), ...context }, path: ['__path__'] }) + + // Only what is left of each window is reflected, so headers never outlive the entry. + expect(pluginContext.caches.map(({ ttl, swr }) => ({ ttl, swr }))).toEqual([ + { ttl: 0, swr: expect.closeTo(20_000, -3) }, + { ttl: expect.closeTo(60_000, -3), swr: 30_000 }, + { ttl: undefined, swr: undefined }, + { ttl: expect.closeTo(60_000, -3), swr: undefined }, + ]) + expect(pluginContext.caches.map(({ tags }) => tags)).toEqual([['stored'], ['stored'], ['t'], ['stored']]) + }) + + it('records stacked caches in lookup order, on misses and hits alike', async () => { + const store = new MemoryCacheStore() + const pluginContext: Exclude = { caches: [], revalidations: [] } + const procedure = os + .$context() + .use(cache({ key: 'outer', tags: ['outer'] })) + .use(cache({ key: 'inner', tags: ['inner'] })) + .handler(() => 'v') + const context = { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } + + await call(procedure, undefined, { context }) + expect(pluginContext.caches.map(({ tags }) => tags)).toEqual([['outer'], ['inner']]) + + await call(procedure, undefined, { context }) + expect(pluginContext.caches.map(({ tags }) => tags)).toEqual([['outer'], ['inner'], ['outer']]) + }) + + it('propagates store failures and records no check', async () => { + const store = createStore() + store.getOrSet.mockRejectedValueOnce(new Error('store down')) + const pluginContext = { caches: [], revalidations: [] } + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => 'fresh') + + await expect( + call(procedure, undefined, { context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } }), + ).rejects.toThrow('store down') + + expect(pluginContext.caches).toEqual([]) + }) + + describe('with the memory store', () => { + it('runs the handler once for concurrent misses', async () => { + const store = new MemoryCacheStore() + let finish!: (output: string) => void + const handlerFn = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const procedure = os.$context().use(cache({ key: 'k' })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) + + const results = Promise.all([run(), run(), run()]) + await vi.waitFor(() => expect(handlerFn).toHaveBeenCalledTimes(1)) + finish('fresh') + + await expect(results).resolves.toEqual(['fresh', 'fresh', 'fresh']) + expect(handlerFn).toHaveBeenCalledTimes(1) + }) + + it('serves concurrent stale hits immediately and refreshes once through waitUntil', async () => { + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'stale', { ttl: 0, swr: 60_000 }) + + let finish!: (output: string) => void + const handlerFn = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + const waitUntil = vi.fn() + const procedure = os.$context().use(cache({ key: 'k', ttl: 60_000 })).handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store, 'cache/waitUntil': waitUntil } }) + + await expect(Promise.all([run(), run()])).resolves.toEqual(['stale', 'stale']) + expect(waitUntil).toHaveBeenCalledTimes(2) + + finish('fresh') + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(handlerFn).toHaveBeenCalledTimes(1) + + await expect(run()).resolves.toBe('fresh') + }) + + it('leaves refresh failures unhandled without waitUntil', async ({ onTestFinished }) => { + // Vitest reports unhandled rejections as failures, so its listeners step aside for this test. + const listeners = process.rawListeners('unhandledRejection') as NodeJS.UnhandledRejectionListener[] + process.removeAllListeners('unhandledRejection') + const unhandledRejectionHandler = vi.fn() + process.on('unhandledRejection', unhandledRejectionHandler) + + onTestFinished(() => { + process.off('unhandledRejection', unhandledRejectionHandler) + for (const listener of listeners) { + process.on('unhandledRejection', listener) + } + }) + + const store = new MemoryCacheStore() + await store.getOrSet('k', async () => 'stale', { ttl: 0, swr: 60_000 }) + const procedure = os.$context().use(cache({ key: 'k' })).handler(() => { + throw new Error('handler down') + }) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).resolves.toBe('stale') + + await vi.waitFor(() => expect(unhandledRejectionHandler).toHaveBeenCalledWith(new Error('handler down'), expect.any(Promise))) + }) + }) +}) + +describe('revalidate', () => { + it('revalidates tags after the handler succeeds', async () => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const order: string[] = [] + store.revalidate.mockImplementation(async () => { + order.push('revalidate') + }) + const procedure = os + .$context() + .use(revalidate({ tags: ['t1', 't2'] })) + .handler(() => { + order.push('handler') + return 'done' + }) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext }, path: ['__path__'] }), + ).resolves.toBe('done') + + expect(order).toEqual(['handler', 'revalidate']) + expect(store.revalidate).toHaveBeenCalledWith({ tags: ['t1', 't2'] }) + expect(pluginContext.revalidations).toEqual([{ procedure, path: ['__path__'], tags: ['t1', 't2'] }]) + }) + + it('tags can be an async function', async () => { + const store = createStore() + const tagsFn = vi.fn().mockResolvedValueOnce(['t']) + const procedure = os.$context().input(type<{ id: number }>()).use(revalidate({ tags: tagsFn })).handler(() => 'done') + + await call(procedure, { id: 1 }, { context: { 'cache/store': store } }) + + expect(tagsFn).toHaveBeenCalledWith(expect.objectContaining({ context: expect.any(Object) }), { id: 1 }) + expect(store.revalidate).toHaveBeenCalledWith({ tags: ['t'] }) + }) + + it('skips the revalidation when the handler throws', async () => { + const store = createStore() + const procedure = os.$context().use(revalidate({ tags: ['t'] })).handler(() => { + throw new Error('handler down') + }) + + await expect( + call(procedure, undefined, { context: { 'cache/store': store } }), + ).rejects.toThrow('handler down') + + expect(store.revalidate).not.toHaveBeenCalled() + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ])('skips the revalidation when tags resolve to %s', async (_, tags) => { + const store = createStore() + const pluginContext = { caches: [], revalidations: [] } + const procedure = os.$context().use(revalidate({ tags: () => tags })).handler(() => 'done') + + await expect( + call(procedure, undefined, { context: { 'cache/store': store, [CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL]: pluginContext } }), + ).resolves.toBe('done') + + expect(store.revalidate).not.toHaveBeenCalled() + expect(pluginContext.revalidations).toEqual([]) + }) +}) + +describe('cache + revalidate combined', () => { + it('never serves an entry whose own fill revalidated one of its tags', async () => { + const store = new MemoryCacheStore() + const revalidateSpy = vi.spyOn(store, 'revalidate') + const handlerFn = vi.fn(() => 'fresh') + const procedure = os + .$context() + .use(cache({ key: 'k', tags: ['t'] })) + .use(revalidate({ tags: ['t'] })) + .handler(handlerFn) + const run = () => call(procedure, undefined, { context: { 'cache/store': store } }) + + // The tag was captured before the fill and bumped during it, so the entry is invalid on arrival. + await expect(run()).resolves.toBe('fresh') + await expect(run()).resolves.toBe('fresh') + + expect(handlerFn).toHaveBeenCalledTimes(2) + expect(revalidateSpy).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/cache/src/middleware.ts b/packages/cache/src/middleware.ts new file mode 100644 index 000000000..09c4faaf6 --- /dev/null +++ b/packages/cache/src/middleware.ts @@ -0,0 +1,157 @@ +import type { Middleware, MiddlewareOptions } from '@orpc/server' +import type { Promisable, Value } from '@orpc/shared' +import type { CacheHandlerPluginContext } from './handler-plugin' +import type { CacheContext, CacheEntry } from './types' +import { value } from '@orpc/shared' +import { CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL } from './handler-plugin' + +export interface CacheMiddlewareOptions< + TInContext extends CacheContext, + TInput, +> { + /** + * The key identifying the cache entry, or any serializable value to derive + * it from. Used as given, so procedures sharing a key share an entry; + * `undefined` falls back to the default. + * + * @default the procedure path and input + */ + // Spelled out instead of `unknown`, which absorbs the function form and drops its contextual typing. + key?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * Tags associated with the entry. Revalidating any of them invalidates the entry. + * + * @default [] + */ + tags?: Value, [options: MiddlewareOptions>, input: TInput]> + + /** + * 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's `cache/store`, + * with tag-based revalidation and optional stale-while-revalidate. + * By default the key is derived from the procedure path and input. + * + * @see {@link https://orpc.dev/docs/helpers/cache#cache-middleware | Cache Helpers - Cache Middleware} + */ +export function cache< + TInContext extends CacheContext, + TInput, +>( + options: CacheMiddlewareOptions = {}, +): Middleware { + return async function cache(middlewareOptions, input, done) { + if (await value(options.enabled, middlewareOptions, input) === false) { + return middlewareOptions.next() + } + + const [key = [middlewareOptions.path, input], tags, ttl, swr] = await Promise.all([ + value(options.key, middlewareOptions, input), + value(options.tags, middlewareOptions, input), + value(options.ttl, middlewareOptions, input), + value(options.swr, middlewareOptions, input), + ]) + + const store = middlewareOptions.context['cache/store'] + const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + const lookupIndex = pluginContext?.caches.length ?? 0 + + const entry = await store.getOrSet(key, async () => (await middlewareOptions.next()).output, { + tags, + ttl, + swr, + waitUntil: middlewareOptions.context['cache/waitUntil'], + }) + + // Recorded at its lookup position rather than appended, so the plugin finds the outermost of stacked caches first. + pluginContext?.caches.splice(lookupIndex, 0, { + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + tags: entry.tags, + ...remainingLifetime(entry), + }) + + return done({ output: entry.output }) + } +} + +export interface RevalidateMiddlewareOptions< + TInContext extends CacheContext, + TInput, +> { + /** + * The tags to revalidate. Resolving to `null` or `undefined` skips the revalidation. + */ + tags: Value, [options: MiddlewareOptions>, input: TInput]> +} + +/** + * Creates a middleware that revalidates cache tags in the context's `cache/store` + * after the procedure succeeds, typically on mutations. Errors skip the revalidation entirely. + * + * @see {@link https://orpc.dev/docs/helpers/cache#revalidate-middleware | Cache Helpers - Revalidate Middleware} + */ +export function revalidate< + TInContext extends CacheContext, + TInput, +>( + options: RevalidateMiddlewareOptions, +): Middleware { + return async function revalidate(middlewareOptions, input) { + const result = await middlewareOptions.next() + + const tags = await value(options.tags, middlewareOptions, input) + + if (tags) { + const store = middlewareOptions.context['cache/store'] + await store.revalidate({ tags }) + + const pluginContext = (middlewareOptions.context as CacheHandlerPluginContext)[CACHE_HANDLER_PLUGIN_CONTEXT_SYMBOL] + pluginContext?.revalidations.push({ + procedure: middlewareOptions.procedure, + path: middlewareOptions.path, + tags, + }) + } + + return result + } +} + +/** + * What is left of the entry's fresh lifetime and swr window, in milliseconds. + */ +function remainingLifetime(entry: CacheEntry): { ttl: number | undefined, swr: number | undefined } { + if (entry.expiresAt === undefined) { + return { ttl: undefined, swr: undefined } + } + + const now = Date.now() + + return { + ttl: Math.max(0, entry.expiresAt - now), + swr: entry.evictAt === undefined ? undefined : Math.max(0, entry.evictAt - Math.max(now, entry.expiresAt)), + } +} diff --git a/packages/cache/src/tiered.test.ts b/packages/cache/src/tiered.test.ts new file mode 100644 index 000000000..ea7492774 --- /dev/null +++ b/packages/cache/src/tiered.test.ts @@ -0,0 +1,82 @@ +import { describeCacheStoreContract } from '../tests/__shared__/store-contract' +import { MemoryCacheStore } from './adapters/memory' +import { TieredCacheStore } from './tiered' + +describe('tieredCacheStore', () => { + describeCacheStoreContract(() => new TieredCacheStore([{ store: new MemoryCacheStore() }, { store: new MemoryCacheStore() }])) + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('fills front tiers from the next one, and only the last tier from the fill', async () => { + const front = new MemoryCacheStore() + const back = new MemoryCacheStore() + const frontGetOrSet = vi.spyOn(front, 'getOrSet') + const backGetOrSet = vi.spyOn(back, 'getOrSet') + const store = new TieredCacheStore([{ store: front }, { store: back }]) + const fill = vi.fn(async () => 'v') + + await back.getOrSet('k', async () => 'from back', { tags: ['t'], ttl: 60_000 }) + + await expect(store.getOrSet('k', fill, { tags: ['t'], ttl: 60_000 })).resolves.toMatchObject({ output: 'from back', tags: ['t'], expiresAt: 60_000 }) + expect(fill).not.toHaveBeenCalled() + expect(backGetOrSet).toHaveBeenCalledTimes(2) + + await expect(store.getOrSet('k', fill, { tags: ['t'], ttl: 60_000 })).resolves.toMatchObject({ output: 'from back' }) + expect(backGetOrSet).toHaveBeenCalledTimes(2) + expect(frontGetOrSet).toHaveBeenCalledTimes(2) + + await expect(store.getOrSet('miss', fill, { ttl: 60_000 })).resolves.toMatchObject({ output: 'v' }) + expect(fill).toHaveBeenCalledTimes(1) + await expect(back.getOrSet('miss', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + await expect(front.getOrSet('miss', async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + + it('caps ttl and swr per tier, refilling a front tier from the next one when its cap passes', async () => { + const front = new MemoryCacheStore() + const back = new MemoryCacheStore() + const store = new TieredCacheStore([{ store: front, ttl: 5000, swr: 0 }, { store: back, ttl: 100_000 }]) + const fill = vi.fn(async () => 'v') + + await expect(store.getOrSet('k', fill, { ttl: 60_000, swr: 30_000 })).resolves.toMatchObject({ expiresAt: 5000, evictAt: 5000 }) + await expect(back.getOrSet('k', fill, { ttl: 60_000, swr: 30_000 })).resolves.toMatchObject({ expiresAt: 60_000, evictAt: 90_000 }) + + vi.setSystemTime(6000) + await expect(store.getOrSet('k', fill, { ttl: 60_000, swr: 30_000 })).resolves.toMatchObject({ output: 'v', expiresAt: 11_000 }) + expect(fill).toHaveBeenCalledTimes(1) + + await expect(store.getOrSet('unbounded', fill)).resolves.toMatchObject({ expiresAt: 11_000, evictAt: 11_000 }) + await expect(back.getOrSet('unbounded', fill)).resolves.toMatchObject({ expiresAt: 106_000, evictAt: 106_000 }) + }) + + it('passes waitUntil through to every tier, and revalidates every tier', async () => { + const front = new MemoryCacheStore() + const back = new MemoryCacheStore() + const backRevalidate = vi.spyOn(back, 'revalidate') + const store = new TieredCacheStore([{ store: front }, { store: back }]) + + await store.getOrSet('k', async () => 'v', { tags: ['t'], ttl: 1000, swr: 10_000 }) + + vi.setSystemTime(2000) + const waitUntil = vi.fn() + await expect(store.getOrSet('k', async () => 'fresh', { tags: ['t'], ttl: 1000, swr: 10_000, waitUntil })).resolves.toMatchObject({ output: 'v' }) + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + expect(waitUntil).toHaveBeenCalledTimes(2) + await expect(back.getOrSet('k', async () => 'other', { tags: ['t'], ttl: 1000, swr: 10_000 })).resolves.toMatchObject({ output: 'fresh' }) + + vi.setSystemTime(4000) // the front copy refreshed from a stale back is stale again, and now refills from the fresh one + await store.getOrSet('k', async () => 'other', { tags: ['t'], ttl: 1000, swr: 10_000, waitUntil }) + await Promise.all(waitUntil.mock.calls.map(([refresh]) => refresh)) + await expect(store.getOrSet('k', async () => 'other', { tags: ['t'], ttl: 1000, swr: 10_000 })).resolves.toMatchObject({ output: 'fresh' }) + + await store.revalidate({ tags: ['t'] }) + expect(backRevalidate).toHaveBeenCalledWith({ tags: ['t'] }) + await expect(store.getOrSet('k', async () => 'refilled', { tags: ['t'], ttl: 1000, swr: 10_000 })).resolves.toMatchObject({ output: 'refilled' }) + }) +}) diff --git a/packages/cache/src/tiered.ts b/packages/cache/src/tiered.ts new file mode 100644 index 000000000..ea5b8f11b --- /dev/null +++ b/packages/cache/src/tiered.ts @@ -0,0 +1,54 @@ +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions, CacheStore } from './types' + +export interface TieredCacheStoreTier { + store: CacheStore + + /** + * Caps `ttl` in this tier, so a front tier refills from the next one + * before the entry behind it can change. + */ + ttl?: number + + /** + * Caps `swr` in this tier. + */ + swr?: number +} + +/** + * Cache store layering several stores front to back, such as a memory store + * in front of a Redis one. A miss in one tier fills from the next, so only + * the last tier runs the fill, and a revalidation reaches every tier. Each + * tier measures `ttl` from when it stored its copy, so cap it on front tiers + * to bound how long they may outlive the entry behind them. + * + * @see {@link https://orpc.dev/docs/helpers/cache#tiered-store | Cache Helpers - Tiered Store} + */ +export class TieredCacheStore implements CacheStore { + constructor(private readonly tiers: readonly [TieredCacheStoreTier, ...TieredCacheStoreTier[]]) {} + + getOrSet(key: unknown, fill: () => Promise, options: CacheGetOrSetOptions = {}): Promise { + return this.getOrSetFrom(0, key, fill, options) + } + + async revalidate(options: CacheRevalidateOptions): Promise { + await Promise.all(this.tiers.map(({ store }) => store.revalidate(options))) + } + + private getOrSetFrom(index: number, key: unknown, fill: () => Promise, options: CacheGetOrSetOptions): Promise { + const { store, ttl, swr } = this.tiers[index]! + const next = index + 1 < this.tiers.length + ? async () => (await this.getOrSetFrom(index + 1, key, fill, options)).output + : fill + + return store.getOrSet(key, next, { + ...options, + ttl: cap(options.ttl, ttl), + swr: cap(options.swr, swr), + }) + } +} + +function cap(value: number | undefined, limit: number | undefined): number | undefined { + return value === undefined ? limit : limit === undefined ? value : Math.min(value, limit) +} diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts new file mode 100644 index 000000000..89d1dd6d6 --- /dev/null +++ b/packages/cache/src/types.ts @@ -0,0 +1,105 @@ +export interface CacheEntry { + /** + * The cached procedure output. + */ + output: unknown + + /** + * The tags recorded when the entry was stored, absent when it has none. + */ + tags?: readonly string[] | undefined + + /** + * The time (unix timestamp in milliseconds) when the entry stops being fresh. + * `undefined` means the entry never becomes stale. + */ + expiresAt?: number | undefined + + /** + * The time (unix timestamp in milliseconds) after which the entry is no longer + * served, once its stale-while-revalidate window has passed. `undefined` + * means the entry never expires. + */ + evictAt?: number | undefined +} + +export interface CacheGetOrSetOptions { + /** + * 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 + + /** + * Takes ownership of the background refresh of a stale entry, like + * `ctx.waitUntil` on Cloudflare Workers. The promise rejects when the + * refresh fails, so this is also where such failures are handled; without + * it they surface as unhandled rejections. + */ + waitUntil?: (promise: Promise) => void +} + +export interface CacheRevalidateOptions { + /** + * The tags to revalidate. + */ + tags: readonly [string, ...string[]] +} + +/** + * Storage contract used by the cache middleware. Implementations own expiry, + * tag tracking, and how concurrent callers of one key are coalesced. + * + * @see {@link https://orpc.dev/docs/helpers/cache#basic-usage | Cache Helpers - Basic Usage} + */ +export interface CacheStore { + /** + * Resolves the entry stored under `key`, filling it through `fill` when + * there is none. Concurrent callers of one key fill once and share that + * entry. A stale entry, past `expiresAt` but within `swr`, is returned as is + * while one caller refreshes it in the background. Keys may be any + * serializable value; implementations encode them stably, so structurally + * equal keys resolve the same entry. + */ + getOrSet(key: unknown, fill: () => Promise, options?: CacheGetOrSetOptions): Promise + + /** + * Invalidates every entry associated with any of the given tags. + */ + revalidate(options: CacheRevalidateOptions): 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/store': CacheStore + + /** + * Takes ownership of background work such as stale-while-revalidate + * refreshes, passed to the store as {@link CacheGetOrSetOptions.waitUntil}. + */ + 'cache/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..06837bac0 --- /dev/null +++ b/packages/cache/src/utils.test.ts @@ -0,0 +1,34 @@ +import { RPCJsonSerializer } from '@orpc/client' +import { encodeCacheKey } from './utils' + +describe('encodeCacheKey', () => { + const serializer = new RPCJsonSerializer() + + it('uses string keys verbatim', () => { + expect(encodeCacheKey('planet:1', serializer)).toBe('planet:1') + expect(encodeCacheKey('{', serializer)).toBe('{') + }) + + it('serializes strings shaped like a serialized key, so they never collide with one', () => { + const encoded = encodeCacheKey({ a: 1 }, serializer) + + expect(encoded).toMatch(/^\{.*\}$/) + expect(encodeCacheKey(encoded, serializer)).not.toBe(encoded) + expect(encodeCacheKey('{}', serializer)).not.toBe('{}') + }) + + it('encodes structurally equal keys identically, regardless of property order', () => { + expect(encodeCacheKey([['planet', 'find'], { b: 2, a: 1 }], serializer)) + .toBe(encodeCacheKey([['planet', 'find'], { a: 1, b: 2 }], serializer)) + + expect(encodeCacheKey({ date: new Date(1), big: 1n }, serializer)) + .toBe(encodeCacheKey({ big: 1n, date: new Date(1) }, serializer)) + + expect(encodeCacheKey({ big: 1n }, serializer)).not.toBe(encodeCacheKey({ big: 2n }, serializer)) + }) + + it('ignores unsupported values like blobs', () => { + expect(encodeCacheKey({ file: new Blob(['a']), id: 1 }, serializer)) + .toBe(encodeCacheKey({ file: new Blob(['b']), id: 1 }, serializer)) + }) +}) diff --git a/packages/cache/src/utils.ts b/packages/cache/src/utils.ts new file mode 100644 index 000000000..83717fdda --- /dev/null +++ b/packages/cache/src/utils.ts @@ -0,0 +1,62 @@ +import type { RPCJsonSerializer } from '@orpc/client' +import type { Public } from '@orpc/shared' +import type { CacheEntry, CacheGetOrSetOptions } from './types' +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. A string shaped like a + * serialized key is serialized too, so the two never collide. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function encodeCacheKey(key: unknown, serializer: Public): string { + if (typeof key === 'string' && (!key.startsWith('{') || !key.endsWith('}'))) { + return key + } + + const { json, meta } = serializer.serialize(key) + + return stringifyJSON({ + j: deepSortKeys(json), + m: meta?.map(entry => stringifyJSON(entry)).sort(), + }) +} + +/** + * Whether the entry is past its fresh lifetime. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function isCacheEntryStale(entry: CacheEntry): boolean { + return entry.expiresAt !== undefined && Date.now() >= entry.expiresAt +} + +/** + * Whether the entry is past the window it may still be served in. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function isCacheEntryEvicted(entry: Pick): boolean { + return entry.evictAt !== undefined && Date.now() >= entry.evictAt +} + +/** + * The entry lifetime an option set describes: when it stops being fresh, + * when it may be evicted (both unix timestamps in milliseconds), and how long + * it is retained in milliseconds. All `undefined` when it never expires. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export function resolveCacheExpiry({ ttl, swr }: CacheGetOrSetOptions): { expiresAt: number | undefined, evictAt: number | undefined, retention: number | undefined } { + if (ttl === undefined) { + return { expiresAt: undefined, evictAt: undefined, retention: undefined } + } + + const expiresAt = Date.now() + ttl + + return { expiresAt, evictAt: expiresAt + (swr ?? 0), retention: ttl + (swr ?? 0) } +} diff --git a/packages/cache/tests/__shared__/redis-store-contract.ts b/packages/cache/tests/__shared__/redis-store-contract.ts new file mode 100644 index 000000000..99e834c53 --- /dev/null +++ b/packages/cache/tests/__shared__/redis-store-contract.ts @@ -0,0 +1,208 @@ +import type { Locker } from '@orpc/experimental-lock' +import type { CacheStore } from '../../src' +import type { BaseRedisCacheStoreOptions } from '../../src/adapters/base-redis' +import { RPCJsonSerializer } from '@orpc/client' +import { sleep, stringifyJSON } from '@orpc/shared' +import { expect, it, vi } from 'vitest' + +export interface RedisCacheStoreContractClient { + exists: (key: string) => Promise + type: (key: string) => Promise + set: (key: string, value: string) => Promise + /** + * A locker sharing locks under `prefix` across stores, as `RedisLocker` does. + */ + createLocker: (options: { prefix: string, ttl: number, timeout: number }) => Locker +} + +/** + * The behavior every Redis-backed store shares, run against one adapter and + * a client that can inspect the keys it writes. `createStore` applies the + * given options over a fresh prefix, or the given one. + */ +export function describeRedisCacheStoreContract( + createStore: (options?: BaseRedisCacheStoreOptions) => { store: CacheStore, prefix: string }, + redis: RedisCacheStoreContractClient, +): void { + it('supports a custom serializer', async () => { + const serializer = new RPCJsonSerializer() + const serializeSpy = vi.spyOn(serializer, 'serialize') + const deserializeSpy = vi.spyOn(serializer, 'deserialize') + const { store } = createStore({ serializer }) + + await store.getOrSet('k', async () => ({ a: 1 })) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: { a: 1 } }) + expect(serializeSpy).toHaveBeenCalled() + expect(deserializeSpy).toHaveBeenCalled() + }) + + it('fills again at ttl without swr, and serves stale within the swr window while refreshing', async () => { + const { store } = createStore() + + await store.getOrSet('no-swr', async () => 'v', { ttl: 1000 }) + await store.getOrSet('swr', async () => 'v', { ttl: 1000, swr: 10_000 }) + + await sleep(1500) + + await expect(store.getOrSet('no-swr', async () => 'refilled', { ttl: 1000 })).resolves.toMatchObject({ output: 'refilled' }) + + const waitUntil = vi.fn() + const stale = await store.getOrSet('swr', async () => 'fresh', { ttl: 1000, swr: 10_000, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(Date.now()) + + expect(waitUntil).toHaveBeenCalledTimes(1) + await waitUntil.mock.calls[0]![0] + + const fresh = await store.getOrSet('swr', async () => 'other', { ttl: 1000, swr: 10_000 }) + expect(fresh.output).toBe('fresh') + expect(fresh.expiresAt).toBeGreaterThan(stale.expiresAt!) + }) + + it('stores entries as strings and tag counters under the prefixed key families', async () => { + const { store, prefix } = createStore() + + await store.getOrSet('k', async () => 'v', { tags: ['t'] }) + await expect(redis.type(`${prefix}e:k`)).resolves.toBe('string') + await expect(redis.exists(`${prefix}t:t`)).resolves.toBe(0) + + await store.revalidate({ tags: ['t'] }) + await expect(redis.type(`${prefix}t:t`)).resolves.toBe('string') + }) + + it('defaults to no prefix', async () => { + const { store } = createStore({ prefix: '' }) + const key = crypto.randomUUID() + + await store.getOrSet(key, async () => 'v') + + await expect(redis.exists(`e:${key}`)).resolves.toBe(1) + await expect(store.getOrSet(key, async () => 'other')).resolves.toMatchObject({ output: 'v' }) + }) + + it('validates against the tags stored with the entry, treating tags missing from the snapshot as version zero', async () => { + const { store, prefix } = createStore() + + await redis.set(`${prefix}e:k`, stringifyJSON({ output: { json: 'v' }, tags: ['stored'] })!) + + await expect(store.getOrSet('k', async () => 'other')).resolves.toMatchObject({ output: 'v', tags: ['stored'] }) + await expect(store.getOrSet('k', async () => 'other', { tags: ['other'] })).resolves.toMatchObject({ output: 'v', tags: ['stored'] }) + + await store.revalidate({ tags: ['stored'] }) + await expect(store.getOrSet('k', async () => 'refilled', { tags: ['other'] })).resolves.toMatchObject({ output: 'refilled', tags: ['other'] }) + }) + + it('evicts entries past evictAt that the server still holds', async () => { + const { store, prefix } = createStore() + + await redis.set(`${prefix}e:k`, stringifyJSON({ output: { json: 'v' }, expiresAt: 1, evictAt: 1 })!) + + await expect(store.getOrSet('k', async () => 'refilled')).resolves.toMatchObject({ output: 'refilled', expiresAt: undefined }) + }) + + it('rejects entries it cannot parse', async () => { + const { store, prefix } = createStore() + + await redis.set(`${prefix}e:broken`, 'not json') + + await expect(store.getOrSet('broken', async () => 'v')).rejects.toThrow() + }) + + it('drops output computed before a revalidation that landed during its fill', async () => { + const { store } = createStore() + let finish!: (output: string) => void + let started!: () => void + const filling = new Promise((resolve) => { + started = resolve + }) + + const first = store.getOrSet('k', () => { + started() + return new Promise((resolve) => { + finish = resolve + }) + }, { tags: ['t'] }) + await filling + await store.revalidate({ tags: ['t'] }) + finish('outdated') + + await expect(first).resolves.toMatchObject({ output: 'outdated' }) + await expect(store.getOrSet('k', async () => 'fresh', { tags: ['t'] })).resolves.toMatchObject({ output: 'fresh' }) + }) + + it('stays consistent under concurrent pending and a revalidation on a shared tag', async () => { + const { store } = createStore() + const keys = Array.from({ length: 20 }, (_, index) => `k${index}`) + + await Promise.all([ + ...keys.map(key => store.getOrSet(key, async () => key, { tags: ['t'] })), + store.revalidate({ tags: ['t'] }), + ]) + + const entries = await Promise.all(keys.map(key => store.getOrSet(key, async () => key, { tags: ['t'] }))) + expect(entries.map(entry => entry.output)).toEqual(keys) + }) + + it('coalesces fills across stores sharing a locker, and lets a waiter fill once its wait times out', async () => { + const { store: holderStore, prefix } = createStore({ locker: redis.createLocker({ prefix: `${crypto.randomUUID()}:`, ttl: 5000, timeout: 5000 }) }) + const { store: waiterStore } = createStore({ prefix, locker: redis.createLocker({ prefix: `${crypto.randomUUID()}:`, ttl: 5000, timeout: 5000 }) }) + const lockPrefix = `${crypto.randomUUID()}:` + const { store: first } = createStore({ prefix, locker: redis.createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 5000 }) }) + const { store: second } = createStore({ prefix, locker: redis.createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 300 }) }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const fill = vi.fn(async () => { + await held + return 'held' + }) + + // Separate lockers never wait for each other. + const unshared = holderStore.getOrSet('unshared', fill) + await vi.waitFor(() => expect(fill).toHaveBeenCalledTimes(1)) + await expect(waiterStore.getOrSet('unshared', async () => 'waiter')).resolves.toMatchObject({ output: 'waiter' }) + + // A shared locker makes the second store wait, then fill itself once its timeout passes. + const holder = first.getOrSet('shared', fill) + await vi.waitFor(() => expect(fill).toHaveBeenCalledTimes(2)) + let settled = false + const waiter = second.getOrSet('shared', async () => 'waiter').then((entry) => { + settled = true + return entry + }) + await sleep(100) + expect(settled).toBe(false) + await expect(waiter).resolves.toMatchObject({ output: 'waiter' }) + + release() + await expect(unshared).resolves.toMatchObject({ output: 'held' }) + await expect(holder).resolves.toMatchObject({ output: 'held' }) + }) + + it('serves a shared-locker waiter the entry the holder stored', async () => { + const lockPrefix = `${crypto.randomUUID()}:` + const { store: first, prefix } = createStore({ locker: redis.createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 5000 }) }) + const { store: second } = createStore({ prefix, locker: redis.createLocker({ prefix: lockPrefix, ttl: 5000, timeout: 5000 }) }) + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const fill = vi.fn(async () => { + await held + return 'held' + }) + + const holder = first.getOrSet('k', fill) + await vi.waitFor(() => expect(fill).toHaveBeenCalledTimes(1)) + const waiterFill = vi.fn(async () => 'waiter') + const waiter = second.getOrSet('k', waiterFill) + await sleep(100) + + release() + await expect(holder).resolves.toMatchObject({ output: 'held' }) + await expect(waiter).resolves.toMatchObject({ output: 'held' }) + expect(waiterFill).not.toHaveBeenCalled() + }) +} diff --git a/packages/cache/tests/__shared__/store-contract.ts b/packages/cache/tests/__shared__/store-contract.ts new file mode 100644 index 000000000..5827c56ef --- /dev/null +++ b/packages/cache/tests/__shared__/store-contract.ts @@ -0,0 +1,116 @@ +import type { CacheStore } from '../../src' +import { expect, it, vi } from 'vitest' + +/** + * The behavior every {@link CacheStore} must share, run against one adapter. + * Adapter suites keep only what is specific to their backend. + */ +export function describeCacheStoreContract(createStore: () => CacheStore): void { + it('fills a miss once, then serves the entry with its tags', async () => { + const store = createStore() + const fill = vi.fn(async () => ({ nested: [1, 2] })) + + await expect(store.getOrSet('k', fill, { tags: ['t'] })).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + await expect(store.getOrSet('k', fill, { tags: ['t'] })).resolves.toEqual({ output: { nested: [1, 2] }, tags: ['t'], expiresAt: undefined }) + expect(fill).toHaveBeenCalledTimes(1) + + await store.getOrSet('u', async () => undefined) + await expect(store.getOrSet('u', async () => 'refilled')).resolves.toEqual({ output: undefined, tags: undefined, expiresAt: undefined }) + }) + + it('fills each key separately', async () => { + const store = createStore() + + await store.getOrSet('a', async () => 'a') + + await expect(store.getOrSet('b', async () => 'b')).resolves.toMatchObject({ output: 'b' }) + await expect(store.getOrSet('a', async () => 'refilled')).resolves.toMatchObject({ output: 'a' }) + }) + + it('preserves Date, Map, Set, and BigInt outputs', async () => { + const store = createStore() + const output = { + date: new Date('2026-01-02T03:04:05.678Z'), + map: new Map([['a', 1]]), + set: new Set([1, 2]), + big: 123n, + } + + await store.getOrSet('k', async () => output) + + await expect(store.getOrSet('k', async () => 'refilled')).resolves.toMatchObject({ output }) + }) + + it('invalidates entries by any of their tags, leaving others alone', async () => { + const store = createStore() + + await store.getOrSet('multi', async () => 'v', { tags: ['a', 'b'] }) + await store.getOrSet('other', async () => 'v', { tags: ['c'] }) + + await store.revalidate({ tags: ['a'] }) + + await expect(store.getOrSet('multi', async () => 'refilled', { tags: ['a', 'b'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.getOrSet('other', async () => 'refilled', { tags: ['c'] })).resolves.toMatchObject({ output: 'v' }) + }) + + it('revalidates many tags at once', async () => { + const store = createStore() + + await store.getOrSet('a', async () => 'v', { tags: ['a'] }) + await store.getOrSet('b', async () => 'v', { tags: ['b'] }) + + await store.revalidate({ tags: ['a', 'b'] }) + + await expect(store.getOrSet('a', async () => 'refilled', { tags: ['a'] })).resolves.toMatchObject({ output: 'refilled' }) + await expect(store.getOrSet('b', async () => 'refilled', { tags: ['b'] })).resolves.toMatchObject({ output: 'refilled' }) + }) + + it('keeps entries filled after a revalidation', async () => { + const store = createStore() + + await store.getOrSet('k', async () => 'old', { tags: ['t'] }) + await store.revalidate({ tags: ['t'] }) + await store.getOrSet('k', async () => 'new', { tags: ['t'] }) + + await expect(store.getOrSet('k', async () => 'newer', { tags: ['t'] })).resolves.toMatchObject({ output: 'new' }) + }) + + it('fills once for concurrent callers of one key', async () => { + const store = createStore() + let finish!: (output: string) => void + const fill = vi.fn(() => new Promise((resolve) => { + finish = resolve + })) + + const pending = Promise.all([store.getOrSet('k', fill), store.getOrSet('k', fill), store.getOrSet('k', fill)]) + await vi.waitFor(() => expect(fill).toHaveBeenCalledTimes(1), { timeout: 5000 }) + finish('v') + + const entries = await pending + expect(entries.map(entry => entry.output)).toEqual(['v', 'v', 'v']) + expect(fill).toHaveBeenCalledTimes(1) + }) + + it('lets a waiter fill when the holder failed to', async () => { + const store = createStore() + let fail!: (error: Error) => void + let started!: () => void + const holding = new Promise((resolve) => { + started = resolve + }) + + const first = store.getOrSet('k', () => { + started() + return new Promise((_, reject) => { + fail = reject + }) + }) + await holding + + const second = store.getOrSet('k', async () => 'fresh') + fail(new Error('handler down')) + + await expect(first).rejects.toThrow('handler down') + await expect(second).resolves.toMatchObject({ output: 'fresh' }) + }) +} diff --git a/packages/cache/tests/e2e.test.ts b/packages/cache/tests/e2e.test.ts new file mode 100644 index 000000000..1fe72f6bb --- /dev/null +++ b/packages/cache/tests/e2e.test.ts @@ -0,0 +1,77 @@ +import type { CacheContext } from '../src' +import { os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import { z } from 'zod' +import { cache, CacheHandlerPlugin, revalidate } from '../src' +import { MemoryCacheStore } from '../src/adapters/memory' + +it('works', async () => { + const findHandlerFn = vi.fn(({ input }) => ({ id: input.id, name: `Planet ${input.id}` })) + + const router = { + planet: { + find: os + .$context() + .input(z.object({ id: z.number() })) + .use( + cache({ + key: (_, input) => `planet:${input.id}`, + tags: (_, input) => ['planets', `planet:${input.id}`], + }), + ) + .handler(findHandlerFn), + update: os + .$context() + .input(z.object({ id: z.number(), name: z.string() })) + .use( + revalidate({ tags: (_, input) => ['planets', `planet:${input.id}`] }), + ) + .handler(({ input }) => input), + }, + } + + const handler = new RPCHandler(router, { + plugins: [ + new CacheHandlerPlugin({ headers: ['orpc-cache-tag', 'orpc-cache-tag-invalidation'] }), + ], + }) + + 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': 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': 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/tests/redis-adapters-compatibility.test.ts b/packages/cache/tests/redis-adapters-compatibility.test.ts new file mode 100644 index 000000000..6466d4f30 --- /dev/null +++ b/packages/cache/tests/redis-adapters-compatibility.test.ts @@ -0,0 +1,132 @@ +import type { CacheStore } from '../src' +import { RedisLocker } from '@orpc/experimental-lock/redis' +import { UpstashLocker } from '@orpc/experimental-lock/upstash' +import { sleep } from '@orpc/shared' +import { Redis } from '@upstash/redis' +import { createClient } from 'redis' +import { RedisCacheStore } from '../src/adapters/redis' +import { UpstashCacheStore } from '../src/adapters/upstash' + +const UPSTASH_REDIS_REST_URL = process.env.UPSTASH_REDIS_REST_URL +const UPSTASH_REDIS_REST_TOKEN = process.env.UPSTASH_REDIS_REST_TOKEN + +/** + * These tests require a real Upstash Redis server. + * Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` before running them. + * + * When adding new tests, always use unique keys to avoid conflicts with other cases. + * + * All adapters must connect to the same server. + */ +describe.concurrent('cache redis adapters compatibility', { timeout: 20_000 }, () => { + const stores: Array<{ name: string, store: CacheStore }> = [] + const prefix = `redis-adapters:${crypto.randomUUID()}:` + const lockPrefix = `${prefix}lock:` + + if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN) { + const redis = createClient({ url: `rediss://default:${UPSTASH_REDIS_REST_TOKEN}@${new URL(UPSTASH_REDIS_REST_URL).host}:6379` }) + + afterAll(() => { + redis.close() + }) + + stores.push({ name: 'redis', store: new RedisCacheStore(redis, { prefix, locker: new RedisLocker(redis, { prefix: lockPrefix, ttl: 10_000 }) }) }) + } + + // TODO: Upstash is not compatible with Node 26 yet — temporarily disable these tests and revisit in the future. + if (UPSTASH_REDIS_REST_URL && UPSTASH_REDIS_REST_TOKEN && !process.versions.node.startsWith('26.')) { + const upstashRedis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }) + + stores.push({ name: 'upstash', store: new UpstashCacheStore(upstashRedis, { prefix, locker: new UpstashLocker(upstashRedis, { prefix: lockPrefix, ttl: 10_000 }) }) }) + } + + describe.skipIf(stores.length < 2)('cross-adapter compatibility', () => { + for (const source of stores) { + for (const target of stores) { + if (source === target) { + continue + } + + it(`shares entries and revalidations: ${source.name} → ${target.name}`, async () => { + const id = crypto.randomUUID() + const tag = `tag:${crypto.randomUUID()}` + const output = { date: new Date('2026-01-02T03:04:05.678Z'), big: 123n } + + await source.store.getOrSet([['planet', 'find'], { b: 2, id }], async () => output, { tags: [tag], ttl: 60_000 }) + + const entry = await target.store.getOrSet([['planet', 'find'], { id, b: 2 }], async () => 'refilled', { tags: [tag], ttl: 60_000 }) + expect(entry.output).toEqual(output) + expect(entry.tags).toEqual([tag]) + expect(entry.expiresAt).toBeGreaterThan(Date.now()) + + await target.store.revalidate({ tags: [tag] }) + + await expect(source.store.getOrSet([['planet', 'find'], { b: 2, id }], async () => 'refilled', { tags: [tag] })).resolves.toMatchObject({ output: 'refilled' }) + }) + + it(`shares tag counters: ${source.name} → ${target.name}`, async () => { + const key = `counter:${crypto.randomUUID()}` + const tag = `tag:${crypto.randomUUID()}` + + await source.store.getOrSet(key, async () => 'v1', { tags: [tag] }) + await target.store.revalidate({ tags: [tag] }) + + await expect(target.store.getOrSet(key, async () => 'v2', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + await expect(source.store.getOrSet(key, async () => 'v3', { tags: [tag] })).resolves.toMatchObject({ output: 'v2' }) + + await source.store.revalidate({ tags: [tag] }) + await expect(target.store.getOrSet(key, async () => 'v4', { tags: [tag] })).resolves.toMatchObject({ output: 'v4' }) + }) + + it(`shares retention: ${source.name} → ${target.name}`, async () => { + const noSwr = `no-swr:${crypto.randomUUID()}` + const swr = `swr:${crypto.randomUUID()}` + + await source.store.getOrSet(noSwr, async () => 'v', { ttl: 1000 }) + await source.store.getOrSet(swr, async () => 'v', { ttl: 1000, swr: 10_000 }) + + await sleep(1500) + + await expect(target.store.getOrSet(noSwr, async () => 'refilled', { ttl: 1000 })).resolves.toMatchObject({ output: 'refilled' }) + + const waitUntil = (_promise: Promise) => {} + const stale = await target.store.getOrSet(swr, async () => 'refilled', { ttl: 1000, swr: 10_000, waitUntil }) + expect(stale.output).toBe('v') + expect(stale.expiresAt).toBeLessThanOrEqual(Date.now()) + }) + + it(`shares locks: ${source.name} → ${target.name}`, async () => { + const key = `lock:${crypto.randomUUID()}` + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + let acquired!: () => void + const holding = new Promise((resolve) => { + acquired = resolve + }) + + const holder = source.store.getOrSet(key, async () => { + acquired() + await held + return 'held' + }) + await holding + + let settled = false + const waiter = target.store.getOrSet(key, async () => 'refilled').then((entry) => { + settled = true + return entry + }) + + await sleep(300) + expect(settled).toBe(false) + + release() + await holder + await expect(waiter).resolves.toMatchObject({ output: 'held' }) + }) + } + } + }) +}) 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 b99bfcf21..68b6ccc67 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 31ce86f76..f5897b87c 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index a44f0c26b..d7bbd6008 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.35", - "description": "Cloudflare integration for oRPC: Durable Object pub/sub and locking, Workers rate limiting, and Workers Traces adapters", + "description": "Cloudflare integration for oRPC: Durable Object pub/sub and locking, Workers rate limiting, Workers Caching, and Workers Traces adapters", "license": "MIT", "funding": [ "https://github.com/sponsors/dinwwwh", @@ -22,6 +22,7 @@ "ratelimit", "lock", "pubsub", + "cache", "tracing", "observability", "typescript" @@ -53,6 +54,7 @@ }, "dependencies": { "@orpc/client": "workspace:*", + "@orpc/experimental-cache": "workspace:*", "@orpc/experimental-lock": "workspace:*", "@orpc/publisher": "workspace:*", "@orpc/ratelimit": "workspace:*", diff --git a/packages/cloudflare/src/index.test.ts b/packages/cloudflare/src/index.test.ts index 06947744c..293ba67ce 100644 --- a/packages/cloudflare/src/index.test.ts +++ b/packages/cloudflare/src/index.test.ts @@ -1,9 +1,10 @@ import { expect, it } from 'vitest' -it('exports CloudflareRateLimiter, experimental_CloudflareTracer, DurablePublisher, DurablePublisherObject, experimental_DurableLocker, experimental_DurableLockObject', async () => { +it('exports CloudflareRateLimiter, experimental_CloudflareTracer, experimental_WorkersCacheStore, DurablePublisher, DurablePublisherObject, experimental_DurableLocker, experimental_DurableLockObject', async () => { await expect(import('./index')).resolves.toMatchObject({ CloudflareRateLimiter: expect.any(Function), experimental_CloudflareTracer: expect.any(Function), + experimental_WorkersCacheStore: expect.any(Function), DurablePublisher: expect.any(Function), DurablePublisherObject: expect.any(Function), experimental_DurableLocker: expect.any(Function), diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 2ec699936..07b677cb9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -5,3 +5,4 @@ export * from './publisher-object' export * from './ratelimit' export type { experimental_CloudflareTracerOptions } from './tracer' export { experimental_CloudflareTracer } from './tracer' +export * from './workers-cache' diff --git a/packages/cloudflare/src/workers-cache.test.ts b/packages/cloudflare/src/workers-cache.test.ts new file mode 100644 index 000000000..df27bf68d --- /dev/null +++ b/packages/cloudflare/src/workers-cache.test.ts @@ -0,0 +1,77 @@ +import { cache } from 'cloudflare:workers' +import { describe, expect, it, vi } from 'vitest' +import { experimental_WorkersCacheStore } from './workers-cache' + +describe('experimental_WorkersCacheStore', () => { + const createPurger = () => ({ + purge: vi.fn(async () => ({ success: true })), + }) as any + + it('fills every time and stores nothing', async () => { + const purger = createPurger() + const store = new experimental_WorkersCacheStore({ cache: purger }) + const fill = vi.fn(async () => 'v') + + const entry = await store.getOrSet('k', fill, { tags: ['t'], ttl: 1000 }) + expect(entry.output).toBe('v') + expect(entry.tags).toEqual(['t']) + expect(entry.expiresAt).toBeGreaterThan(Date.now()) + + await expect(store.getOrSet('k', fill)).resolves.toEqual({ output: 'v', tags: undefined, expiresAt: undefined }) + expect(fill).toHaveBeenCalledTimes(2) + expect(purger.purge).not.toHaveBeenCalled() + }) + + it('measures expiry from when the fill finishes', async () => { + const store = new experimental_WorkersCacheStore({ cache: createPurger() }) + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000_000_000) + + const entry = await store.getOrSet('k', async () => { + now.mockReturnValue(1_000_000_005_000) + return 'v' + }, { ttl: 10_000 }) + + expect(entry.expiresAt).toBe(1_000_000_015_000) + now.mockRestore() + }) + + it('purges encoded tags through workers caching', async () => { + const purger = createPurger() + const store = new experimental_WorkersCacheStore({ cache: purger }) + + await store.revalidate({ tags: ['planets', 'a,b'] }) + + expect(purger.purge).toHaveBeenCalledTimes(1) + expect(purger.purge).toHaveBeenCalledWith({ tags: ['planets', 'a%2Cb'] }) + }) + + it('defaults to the cache exported by cloudflare:workers', () => { + const store = new experimental_WorkersCacheStore() + + expect((store as any).cache).toBe(cache) + }) + + it('throws a bare error when the purge fails without messages', async () => { + const store = new experimental_WorkersCacheStore({ + cache: { + purge: vi.fn(async () => ({ success: false })) as any, + }, + }) + + await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( + 'experimental_WorkersCacheStore failed to purge tags', + ) + }) + + it('throws when the purge fails, including error messages', async () => { + const store = new experimental_WorkersCacheStore({ + cache: { + purge: vi.fn(async () => ({ success: false, errors: [{ code: 429, message: 'Rate limited' }] })), + }, + }) + + await expect(store.revalidate({ tags: ['planets'] })).rejects.toThrow( + 'experimental_WorkersCacheStore failed to purge tags: Rate limited', + ) + }) +}) diff --git a/packages/cloudflare/src/workers-cache.ts b/packages/cloudflare/src/workers-cache.ts new file mode 100644 index 000000000..40f5d9183 --- /dev/null +++ b/packages/cloudflare/src/workers-cache.ts @@ -0,0 +1,47 @@ +import type { CacheEntry, CacheGetOrSetOptions, CacheRevalidateOptions, CacheStore } from '@orpc/experimental-cache' +import { resolveCacheExpiry } from '@orpc/experimental-cache' +import { encodeCacheTag, toArray } from '@orpc/shared' +import * as workers from 'cloudflare:workers' + +export interface experimental_WorkersCacheStoreOptions { + /** + * The Workers Caching purge surface, such as `ctx.cache`. + * + * @default cache from `cloudflare:workers` + */ + cache?: typeof workers.cache +} + +/** + * 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 `getOrSet` always fills and + * stores nothing; `revalidate` purges the tags through Workers Caching. + * + * @see {@link https://orpc.dev/docs/helpers/cache#adapters | Cache Helpers - Adapters} + */ +export class experimental_WorkersCacheStore implements CacheStore { + private readonly cache: typeof workers.cache + + constructor(options: experimental_WorkersCacheStoreOptions = {}) { + this.cache = options.cache ?? workers.cache + } + + async getOrSet(_key: unknown, fill: () => Promise, options: CacheGetOrSetOptions = {}): Promise { + const output = await fill() + const { expiresAt, evictAt } = resolveCacheExpiry(options) + + return { output, tags: options.tags, expiresAt, evictAt } + } + + async revalidate({ tags }: CacheRevalidateOptions): Promise { + const result = await this.cache.purge({ + tags: tags.map(tag => encodeCacheTag(tag)), + }) + + 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/contract/README.md b/packages/contract/README.md index 199330f86..3d5cfa4e9 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/effect/README.md b/packages/effect/README.md index ee624247b..d30e3bb9f 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/evlog/README.md b/packages/evlog/README.md index e5238e8cc..1c18c198a 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/hibernation/README.md b/packages/hibernation/README.md index 9ab1d09cb..56337ffa9 100644 --- a/packages/hibernation/README.md +++ b/packages/hibernation/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 4b48d85ff..e5ade2a77 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/msw/README.md b/packages/msw/README.md index 350901628..71caa94dd 100644 --- a/packages/msw/README.md +++ b/packages/msw/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/nest/README.md b/packages/nest/README.md index c7daa1500..adfb090c5 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/next/README.md b/packages/next/README.md index 70d68d3c2..7d6f34927 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/node/README.md b/packages/node/README.md index 51d3901d0..32ff6adc5 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/openapi/README.md b/packages/openapi/README.md index 4fb512aa6..7f1ae8296 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 35049c0b4..9dadd24de 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 94c8854b7..b205c4912 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/pino/README.md b/packages/pino/README.md index 7fabec689..78cc3323d 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 91f765122..4d2c6ef9c 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index dda86c1f0..aa7640bdb 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/server/README.md b/packages/server/README.md index f3aa43893..f2573c495 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/shared/README.md b/packages/shared/README.md index a8b660718..88249aac7 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/shared/src/http.test.ts b/packages/shared/src/http.test.ts index 37adc75e3..d7a27f3fb 100644 --- a/packages/shared/src/http.test.ts +++ b/packages/shared/src/http.test.ts @@ -1,4 +1,7 @@ import { + decodeCacheTagHeader, + encodeCacheTag, + encodeCacheTagHeader, isCompressibleContentType, isNoTransformCacheControl, matchesHttpPath, @@ -9,6 +12,7 @@ import { pathToHttpPath, varyByAcceptEncoding, } from './http' +import { safeDecodeURIComponent } from './uri' describe('pathToHttpPath', () => { it('produces a leading slash', () => { @@ -248,3 +252,51 @@ describe('isNoTransformCacheControl', () => { expect(isNoTransformCacheControl('no-transform-extension')).toBe(false) }) }) + +describe('encodeCacheTag', () => { + it.each([ + ['leaves plain tags alone', 'planets', 'planets'], + ['leaves other printable ASCII alone', 'a1!~*\'()-_.:/?', 'a1!~*\'()-_.:/?'], + ['escapes the comma separator', 'a,b', 'a%2Cb'], + ['escapes the percent escape', '100%', '100%25'], + ['escapes uppercase letters by code point', 'Planets', '%50lanets'], + ['escapes spaces', 'sp ace', 'sp%20ace'], + ['escapes control characters', 'a\nb', 'a%0Ab'], + ['escapes delete', 'a\x7Fb', 'a%7Fb'], + ['escapes non-ASCII as UTF-8', 'tiếng việt', 'ti%E1%BA%BFng%20vi%E1%BB%87t'], + ['escapes astral characters as UTF-8', 'a😀', 'a%F0%9F%98%80'], + ])('%s', (_, tag, encoded) => { + expect(encodeCacheTag(tag)).toBe(encoded) + expect(safeDecodeURIComponent(encoded)).toBe(tag) + }) + + it('keeps case-folded tags distinct', () => { + expect(encodeCacheTag('Planets')).not.toBe(encodeCacheTag('planets')) + expect(encodeCacheTag('Planets').toLowerCase()).not.toBe(encodeCacheTag('planets').toLowerCase()) + }) + + it('encodes identically across calls, since the pattern is shared', () => { + expect(encodeCacheTag('A,B%C')).toBe(encodeCacheTag('A,B%C')) + expect(encodeCacheTag('A,B%C')).toBe('%41%2C%42%25%43') + }) +}) + +describe('encodeCacheTagHeader & decodeCacheTagHeader', () => { + it('joins encoded tags with commas, and round-trips the list', () => { + const tags = ['plain', 'a,b', '100%', 'CamelCase', 'tiếng việt', 'sp ace'] + + expect(encodeCacheTagHeader(['a', 'b'])).toBe('a,b') + expect(encodeCacheTagHeader(tags)).toBe(tags.map(tag => encodeCacheTag(tag)).join(',')) + expect(decodeCacheTagHeader(encodeCacheTagHeader(tags))).toEqual(tags) + }) + + it('keeps empty tags instead of dropping them', () => { + expect(encodeCacheTagHeader(['a', '', 'b'])).toBe('a,,b') + expect(decodeCacheTagHeader('a,,b')).toEqual(['a', '', 'b']) + expect(decodeCacheTagHeader('')).toEqual(['']) + }) + + it('decodes malformed escapes as-is', () => { + expect(decodeCacheTagHeader('%zz')).toEqual(['%zz']) + }) +}) diff --git a/packages/shared/src/http.ts b/packages/shared/src/http.ts index 0f1e9b035..b168a0342 100644 --- a/packages/shared/src/http.ts +++ b/packages/shared/src/http.ts @@ -129,3 +129,54 @@ export function isCompressibleContentType(contentType: string | null | undefined return COMPRESSIBLE_CONTENT_TYPE_REGEX.test(contentType) } + +/** + * Characters a cache tag cannot carry literally: `%` and `,` because they are + * the encoding's own syntax, `A-Z` because caches like Cloudflare Workers + * Caching fold case, and anything outside printable ASCII because it cannot + * appear in a header value. + * + * Safe to share across calls: `String.prototype.replace` resets `lastIndex` + * on a global regex before it matches. + */ +const UNSAFE_CACHE_TAG_CHARS = /[^\x21-\x7E]|[%,A-Z]/gu + +/** + * Percent-encodes only {@link UNSAFE_CACHE_TAG_CHARS} in a cache tag, so + * typical tags stay readable. Uppercase letters are encoded because caches + * like Cloudflare Workers Caching match tags case-insensitively; the encoded + * form stays unambiguous under case folding. Reach for this where tags travel + * one at a time, such as a purge API. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function encodeCacheTag(tag: string): string { + // encodeURIComponent emits the UTF-8 bytes a percent escape needs, but leaves + // the URI unreserved set alone, so the uppercase letters in it are escaped by + // hand. Those are single-byte ASCII, so the code point is the byte. + return tag.replace( + UNSAFE_CACHE_TAG_CHARS, + char => char >= 'A' && char <= 'Z' + ? `%${char.charCodeAt(0).toString(16).toUpperCase()}` + : safeEncodeURIComponent(char), + ) +} + +/** + * Joins {@link encodeCacheTag}-encoded tags with commas into a header value. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function encodeCacheTagHeader(tags: readonly string[]): string { + return tags.map(tag => encodeCacheTag(tag)).join(',') +} + +/** + * Decodes a header value produced by {@link encodeCacheTagHeader} back into + * tags, empty ones included. Every non-empty tag list round-trips exactly. + * + * @see {@link https://orpc.dev/docs/helpers/cache#handler-plugin | Cache Helpers - Handler Plugin} + */ +export function decodeCacheTagHeader(header: string): string[] { + return header.split(',').map(safeDecodeURIComponent) +} diff --git a/packages/shared/src/object.test.ts b/packages/shared/src/object.test.ts index 4b47c8c88..f31a0d957 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, setOwn } from './object' +import { bindMethods, clone, deepSortKeys, findDeepMatches, get, getConstructor, getConstructors, getOwn, isPlainObject, isPropertyKey, mergeTwoLevels, NullProtoObj, omit, set, setOwn } from './object' it('findDeepMatches', () => { const { maps, values } = findDeepMatches(v => typeof v === 'string', { @@ -726,3 +726,29 @@ 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('keeps an own __proto__ key as data', () => { + const sorted = deepSortKeys(JSON.parse('{"b": 1, "__proto__": {"x": 1}}')) as Record + + expect(Object.keys(sorted)).toEqual(['__proto__', 'b']) + expect(JSON.stringify(sorted)).toBe('{"__proto__":{"x":1},"b":1}') + }) + + 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 cf78a1668..5f2c99d9f 100644 --- a/packages/shared/src/object.ts +++ b/packages/shared/src/object.ts @@ -282,3 +282,19 @@ export function bindMethods( return methods as any } + +export function deepSortKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(deepSortKeys) + } + + if (isPlainObject(value)) { + const sorted: Record = new NullProtoObj() + for (const key of Object.keys(value).sort()) { + sorted[key] = deepSortKeys(value[key]) + } + return sorted + } + + return value +} diff --git a/packages/swr/README.md b/packages/swr/README.md index 33b48e950..1e6e2cdea 100644 --- a/packages/swr/README.md +++ b/packages/swr/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 0fd8e8c5d..3fbc39037 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/trpc/README.md b/packages/trpc/README.md index 69d328579..985e9131c 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/valibot/README.md b/packages/valibot/README.md index 8a2ee7771..cf717de92 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/packages/zod/README.md b/packages/zod/README.md index fd84e4dc4..9acae1458 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -46,13 +46,14 @@ You can read the documentation [here](https://orpc.dev). **Built-in features** -| Package | Purpose | Docs | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | -| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | -| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | -| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | -| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | -| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | +| Package | Purpose | Docs | +| ---------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](https://orpc.dev/docs/helpers/publisher) | +| [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](https://orpc.dev/docs/helpers/ratelimit) | +| [@orpc/experimental-cache](https://www.npmjs.com/package/@orpc/experimental-cache) | Tag-based caching and revalidation with memory, Redis, Upstash, Vercel, and Cacheable adapters. | [Cache](https://orpc.dev/docs/helpers/cache) | +| [@orpc/experimental-lock](https://www.npmjs.com/package/@orpc/experimental-lock) | Locking with memory, Redis, and Upstash adapters. | [Lock](https://orpc.dev/docs/helpers/lock) | +| [@orpc/hibernation](https://www.npmjs.com/package/@orpc/hibernation) | Leverage Hibernation APIs like [Cloudflare's Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/#durable-objects-hibernation-websocket-api). | [Hibernation](https://orpc.dev/docs/integrations/hibernation) | +| [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](https://orpc.dev/docs/plugins/smart-coercion) | **Framework & ecosystem integrations** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bffd5354..cf4d83e85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -190,6 +190,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 @@ -376,6 +379,9 @@ importers: '@orpc/evlog': specifier: workspace:* version: link:../../packages/evlog + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../../packages/cache '@orpc/experimental-lock': specifier: workspace:* version: link:../../packages/lock @@ -423,7 +429,7 @@ importers: version: 26.4.0 blume: specifier: ^1.5.3 - version: 1.5.3(@astrojs/cloudflare@14.2.5(@types/node@26.4.0)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(wrangler@4.129.1)(yaml@2.9.0))(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(esbuild@0.28.2)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(terser@5.51.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0) + version: 1.5.3(3d73391e37eb1d8a704453efacb9694b) effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112 @@ -483,6 +489,9 @@ importers: '@orpc/client': specifier: workspace:* version: link:../client + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../cache '@orpc/experimental-lock': specifier: workspace:* version: link:../lock @@ -507,7 +516,38 @@ importers: version: 1.4.2 redis: specifier: ^6.2.1 - version: 6.2.1(@opentelemetry/api@1.9.1) + version: 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) + + packages/cache: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/experimental-lock': + specifier: workspace:* + version: link:../lock + '@orpc/server': + specifier: workspace:* + version: link:../server + '@orpc/shared': + specifier: workspace:* + version: link:../shared + '@standard-server/core': + specifier: ~0.9.0 + version: 0.9.0 + devDependencies: + '@upstash/redis': + specifier: ^1.38.2 + version: 1.38.3 + '@vercel/functions': + specifier: ^3.9.5 + version: 3.9.5(ws@8.21.3) + cacheable: + specifier: ^2.5.0 + version: 2.5.0 + redis: + specifier: ^6.2.1 + version: 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) packages/client: dependencies: @@ -533,6 +573,9 @@ importers: '@orpc/client': specifier: workspace:* version: link:../client + '@orpc/experimental-cache': + specifier: workspace:* + version: link:../cache '@orpc/experimental-lock': specifier: workspace:* version: link:../lock @@ -682,7 +725,7 @@ importers: version: 1.38.3 redis: specifier: ^6.2.1 - version: 6.2.1(@opentelemetry/api@1.9.1) + version: 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) packages/msw: dependencies: @@ -949,7 +992,7 @@ importers: version: 1.38.3 redis: specifier: ^6.2.1 - version: 6.2.1(@opentelemetry/api@1.9.1) + version: 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) packages/ratelimit: dependencies: @@ -971,7 +1014,7 @@ importers: version: 1.38.3 redis: specifier: ^6.2.1 - version: 6.2.1(@opentelemetry/api@1.9.1) + version: 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) packages/server: dependencies: @@ -2028,6 +2071,12 @@ packages: cpu: [x64] os: [win32] + '@cacheable/memory@2.2.0': + resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} + + '@cacheable/utils@2.5.0': + resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} + '@capsizecss/unpack@4.0.1': resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} engines: {node: '>=18'} @@ -3453,6 +3502,15 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lezer/common@1.5.2': resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} @@ -7048,6 +7106,9 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + cacheable@2.5.0: + resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -8708,6 +8769,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -8796,6 +8861,12 @@ packages: hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hookified@2.2.0: + resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -9336,6 +9407,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} @@ -10804,6 +10878,10 @@ packages: pure-rand@8.4.2: resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} + qified@0.10.1: + resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} + engines: {node: '>=20'} + qs@6.15.3: resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} @@ -12971,9 +13049,9 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@astrojs/check@0.9.10(typescript@6.0.3)': + '@astrojs/check@0.9.10(prettier@3.9.6)(typescript@6.0.3)': dependencies: - '@astrojs/language-server': 2.16.15(typescript@6.0.3) + '@astrojs/language-server': 2.16.15(prettier@3.9.6)(typescript@6.0.3) chokidar: 4.0.3 kleur: 4.1.5 typescript: 6.0.3 @@ -13074,7 +13152,7 @@ snapshots: smol-toml: 1.8.0 unified: 11.0.5 - '@astrojs/language-server@2.16.15(typescript@6.0.3)': + '@astrojs/language-server@2.16.15(prettier@3.9.6)(typescript@6.0.3)': dependencies: '@astrojs/compiler': 2.13.1 '@astrojs/yaml2ts': 0.2.4 @@ -13088,12 +13166,14 @@ snapshots: volar-service-css: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) volar-service-emmet: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) volar-service-html: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) - volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.6) volar-service-typescript: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3) volar-service-yaml: 0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)) vscode-html-languageservice: 5.6.2 vscode-uri: 3.2.0 + optionalDependencies: + prettier: 3.9.6 transitivePeerDependencies: - typescript @@ -13196,12 +13276,12 @@ snapshots: '@astrojs/underscore-redirects@1.0.4': {} - '@astrojs/vercel@11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(react@19.2.8)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3)': + '@astrojs/vercel@11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.63.0)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3)': dependencies: '@astrojs/internal-helpers': 0.10.4 - '@vercel/analytics': 1.6.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) + '@vercel/analytics': 1.6.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) '@vercel/functions': 3.9.5(ws@8.21.3) - '@vercel/nft': 1.11.0(supports-color@10.2.2) + '@vercel/nft': 1.11.0(rollup@4.63.0)(supports-color@10.2.2) '@vercel/routing-utils': 5.3.3 astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) esbuild: 0.28.2 @@ -13459,6 +13539,18 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.9.5': optional: true + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@capsizecss/unpack@4.0.1': dependencies: fontkitten: 1.0.3 @@ -14622,6 +14714,14 @@ snapshots: dependencies: jsep: 1.4.0 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@lezer/common@1.5.2': {} '@lezer/css@1.3.6': @@ -15854,10 +15954,10 @@ snapshots: '@phosphor-icons/core@2.1.1': {} - '@pierre/diffs@1.3.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@pierre/diffs@1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@pierre/theme': 2.0.0 - '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3) + '@pierre/theming': 1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3) '@shikijs/transformers': 4.4.3 diff: 9.0.0 hast-util-to-html: 9.0.5 @@ -15870,9 +15970,10 @@ snapshots: '@pierre/theme@2.0.0': {} - '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)': + '@pierre/theming@1.0.1(@pierre/theme@2.0.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.4.3)': optionalDependencies: '@pierre/theme': 2.0.0 + '@shikijs/themes': 4.4.3 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) shiki: 4.4.3 @@ -15926,27 +16027,28 @@ snapshots: dependencies: quansync: 1.0.0 - '@redis/bloom@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/bloom@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: - '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/client@6.2.1(@opentelemetry/api@1.9.1)': + '@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)': dependencies: cluster-key-slot: 1.1.2 optionalDependencies: + '@node-rs/xxhash': 1.7.7 '@opentelemetry/api': 1.9.1 - '@redis/json@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/json@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: - '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/search@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/search@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: - '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) - '@redis/time-series@6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1))': + '@redis/time-series@6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1))': dependencies: - '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) + '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) '@replit/codemirror-css-color-picker@6.3.0(@codemirror/language@6.12.4)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)': dependencies: @@ -17394,7 +17496,7 @@ snapshots: '@takumi-rs/core-win32-x64-msvc@2.12.0': optional: true - '@takumi-rs/core@2.12.0(react@19.2.8)': + '@takumi-rs/core@2.12.0(csstype@3.2.3)(react@19.2.8)': dependencies: '@takumi-rs/helpers': 2.12.0(react@19.2.8) optionalDependencies: @@ -17406,6 +17508,7 @@ snapshots: '@takumi-rs/core-linux-x64-musl': 2.12.0 '@takumi-rs/core-win32-arm64-msvc': 2.12.0 '@takumi-rs/core-win32-x64-msvc': 2.12.0 + csstype: 3.2.3 transitivePeerDependencies: - preact - react @@ -17414,9 +17517,11 @@ snapshots: optionalDependencies: react: 19.2.8 - '@takumi-rs/wasm@2.12.0(react@19.2.8)': + '@takumi-rs/wasm@2.12.0(csstype@3.2.3)(react@19.2.8)': dependencies: '@takumi-rs/helpers': 2.12.0(react@19.2.8) + optionalDependencies: + csstype: 3.2.3 transitivePeerDependencies: - preact - react @@ -18016,14 +18121,16 @@ snapshots: dependencies: valibot: 1.5.0(typescript@6.0.3) - '@vercel/analytics@1.6.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': + '@vercel/analytics@1.6.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': optionalDependencies: + next: 16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0) vue: 3.5.42(typescript@6.0.3) - '@vercel/analytics@2.0.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': + '@vercel/analytics@2.0.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))': optionalDependencies: + next: 16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0) vue: 3.5.42(typescript@6.0.3) @@ -18043,7 +18150,7 @@ snapshots: optionalDependencies: ws: 8.21.3 - '@vercel/nft@1.11.0(supports-color@10.2.2)': + '@vercel/nft@1.11.0(rollup@4.63.0)(supports-color@10.2.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3(supports-color@10.2.2) '@rollup/pluginutils': 5.4.0(rollup@4.63.0) @@ -18826,14 +18933,14 @@ snapshots: blake3-wasm@2.1.5: {} - blume@1.5.3(@astrojs/cloudflare@14.2.5(@types/node@26.4.0)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(wrangler@4.129.1)(yaml@2.9.0))(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(esbuild@0.28.2)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(terser@5.51.1)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3)(yaml@2.9.0): + blume@1.5.3(3d73391e37eb1d8a704453efacb9694b): dependencies: - '@astrojs/check': 0.9.10(typescript@6.0.3) + '@astrojs/check': 0.9.10(prettier@3.9.6)(typescript@6.0.3) '@astrojs/markdown-satteri': 0.3.8 '@astrojs/mdx': 7.0.8(@astrojs/markdown-satteri@0.3.8)(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2) '@astrojs/node': 11.1.4(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(supports-color@10.2.2) '@astrojs/react': 6.0.4(@types/node@26.4.0)(@types/react-dom@19.2.5(@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.51.1)(yaml@2.9.0) - '@astrojs/vercel': 11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(react@19.2.8)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3) + '@astrojs/vercel': 11.0.8(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0))(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.63.0)(supports-color@10.2.2)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3))(ws@8.21.3) '@asyncapi/converter': 2.0.2 '@clack/prompts': 1.7.0 '@iconify-json/lucide': 1.2.126 @@ -18841,7 +18948,7 @@ snapshots: '@iconify/utils': 3.1.4 '@modelcontextprotocol/sdk': 1.30.0(supports-color@10.2.2)(zod@4.5.4) '@orama/orama': 3.1.18 - '@pierre/diffs': 1.3.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@pierre/diffs': 1.3.6(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@scalar/astro': 0.4.16(astro@7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) '@scalar/openapi-parser': 0.28.16 '@scalar/openapi-types': 0.9.5 @@ -18850,7 +18957,7 @@ snapshots: '@tailwindcss/typography': 0.5.20(tailwindcss@4.3.3) '@tailwindcss/vite': 4.3.3(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0)) '@types/mdast': 4.0.4 - '@vercel/analytics': 2.0.1(react@19.2.8)(svelte@5.56.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) + '@vercel/analytics': 2.0.1(next@16.3.3(@babel/core@7.29.7(supports-color@10.2.2))(@opentelemetry/api@1.9.1)(@types/node@26.4.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.10(@typescript-eslint/types@8.68.0))(vue@3.5.42(typescript@6.0.3)) ai: 7.0.83(zod@4.5.4) astro: 7.2.8(@astrojs/markdown-remark@7.2.4(supports-color@10.2.2))(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@26.4.0)(@upstash/redis@1.38.3)(@vercel/functions@3.9.5(ws@8.21.3))(jiti@2.7.0)(terser@5.51.1)(yaml@2.9.0) babel-plugin-react-compiler: 1.0.0 @@ -18899,7 +19006,7 @@ snapshots: simple-icons: 13.21.0 string-width: 8.2.2 tailwindcss: 4.3.3 - takumi-js: 2.12.0(react@19.2.8) + takumi-js: 2.12.0(csstype@3.2.3)(react@19.2.8) tinyglobby: 0.2.17 twoslash: 0.3.9(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 @@ -19073,6 +19180,14 @@ snapshots: cac@7.0.0: {} + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -21017,6 +21132,10 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -21213,6 +21332,10 @@ snapshots: hookable@6.1.1: {} + hookified@1.15.1: {} + + hookified@2.2.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) @@ -21721,6 +21844,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + khroma@2.1.0: {} kind-of@6.0.3: {} @@ -23465,6 +23592,10 @@ snapshots: pure-rand@8.4.2: {} + qified@0.10.1: + dependencies: + hookified: 2.2.0 + qs@6.15.3: dependencies: es-define-property: 1.0.1 @@ -23651,13 +23782,13 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 - redis@6.2.1(@opentelemetry/api@1.9.1): + redis@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1): dependencies: - '@redis/bloom': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) - '@redis/client': 6.2.1(@opentelemetry/api@1.9.1) - '@redis/json': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) - '@redis/search': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) - '@redis/time-series': 6.2.1(@redis/client@6.2.1(@opentelemetry/api@1.9.1)) + '@redis/bloom': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) + '@redis/client': 6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1) + '@redis/json': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) + '@redis/search': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) + '@redis/time-series': 6.2.1(@redis/client@6.2.1(@node-rs/xxhash@1.7.7)(@opentelemetry/api@1.9.1)) transitivePeerDependencies: - '@node-rs/xxhash' - '@opentelemetry/api' @@ -24723,11 +24854,11 @@ snapshots: tailwindcss@4.3.3: {} - takumi-js@2.12.0(react@19.2.8): + takumi-js@2.12.0(csstype@3.2.3)(react@19.2.8): dependencies: - '@takumi-rs/core': 2.12.0(react@19.2.8) + '@takumi-rs/core': 2.12.0(csstype@3.2.3)(react@19.2.8) '@takumi-rs/helpers': 2.12.0(react@19.2.8) - '@takumi-rs/wasm': 2.12.0(react@19.2.8) + '@takumi-rs/wasm': 2.12.0(csstype@3.2.3)(react@19.2.8) transitivePeerDependencies: - csstype - preact @@ -25310,11 +25441,12 @@ snapshots: optionalDependencies: '@volar/language-service': 2.4.28(typescript@6.0.3) - volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3)): + volar-service-prettier@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(prettier@3.9.6): dependencies: vscode-uri: 3.2.0 optionalDependencies: '@volar/language-service': 2.4.28(typescript@6.0.3) + prettier: 3.9.6 volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28(typescript@6.0.3))(typescript@6.0.3): dependencies: diff --git a/tests/plugins/all-plugins.test.ts b/tests/plugins/all-plugins.test.ts index 132dfbb0d..25e0fb6a2 100644 --- a/tests/plugins/all-plugins.test.ts +++ b/tests/plugins/all-plugins.test.ts @@ -16,6 +16,7 @@ import { import { oc } from '@orpc/contract' import { RequestValidationLinkPlugin, ResponseValidationLinkPlugin } from '@orpc/contract/plugins' import { EvlogHandlerPlugin } from '@orpc/evlog' +import { CacheHandlerPlugin } from '@orpc/experimental-cache' import { HibernationHandlerPlugin } from '@orpc/hibernation' import { SmartCoercionHandlerPlugin, SmartCoercionLinkPlugin } from '@orpc/json-schema' import { BatchResponseCompressionHandlerPlugin, StaticFileHandlerPlugin, TmpFileUploadHandlerPlugin } from '@orpc/node' @@ -71,6 +72,7 @@ const router = implementer.router({ function createHandlerPlugins() { return [ + new CacheHandlerPlugin({ headers: ['cache-control'] }), new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin({ threshold: 0 }), new CORSHandlerPlugin(),