diff --git a/README.md b/README.md index dc7a2c230..f99660aa5 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/apps/content/.vitepress/config.ts b/apps/content/.vitepress/config.ts index 5e8c4e69d..123ef4b9c 100644 --- a/apps/content/.vitepress/config.ts +++ b/apps/content/.vitepress/config.ts @@ -196,6 +196,7 @@ export default withMermaid(defineConfig({ { text: 'OpenTelemetry', link: '/docs/integrations/opentelemetry' }, { text: 'Pinia Colada', link: '/docs/integrations/pinia-colada' }, { text: 'Pino', link: '/docs/integrations/pino' }, + { text: 'SWR', link: '/docs/integrations/swr' }, { text: 'Standard Schema', link: '/docs/integrations/standard-schema' }, { text: 'Tanstack Query', link: '/docs/integrations/tanstack-query' }, { text: 'tRPC', link: '/docs/integrations/trpc' }, diff --git a/apps/content/docs/integrations/swr.md b/apps/content/docs/integrations/swr.md new file mode 100644 index 000000000..85b980f3f --- /dev/null +++ b/apps/content/docs/integrations/swr.md @@ -0,0 +1,180 @@ +# SWR Integration + +[SWR](https://swr.vercel.app/) is a React Hooks library for data fetching that provides features like caching, revalidation, and more. oRPC SWR integration is very lightweight and straightforward. There is no extra overhead. + +::: warning +This guide assumes you are already familiar with [SWR](https://swr.vercel.app/). If you need a refresher, review the official SWR documentation before continuing. +::: + +## Installation + +::: code-group + +```sh [npm] +npm install @orpc/swr@beta +``` + +```sh [yarn] +yarn add @orpc/swr@beta +``` + +```sh [pnpm] +pnpm add @orpc/swr@beta +``` + +```sh [bun] +bun add @orpc/swr@beta +``` + +```sh [deno] +deno add npm:@orpc/swr@beta +``` + +::: + +## Setup + +Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). + +```ts +import { createSWRUtils } from '@orpc/swr' + +export const orpc = createSWRUtils(client) + +orpc.planet.find.key({ input: { id: 123 } }) +``` + +::: details Avoiding Key Conflicts? + +You can avoid key conflicts by passing a unique prefix when creating your utils: + +```ts +const userORPC = createSWRUtils(userClient, { + prefix: 'user' +}) + +const postORPC = createSWRUtils(postClient, { + prefix: 'post' +}) +``` + +::: + +## Data Fetching + +Use `.key` and `.fetcher` methods to configure `useSWR` for data fetching: + +```ts +import useSWR from 'swr' + +const { data, error, isLoading } = useSWR( + orpc.planet.find.key({ input: { id: 123 } }), + orpc.planet.find.fetcher({ context: { cache: true } }), // Provide client context if needed +) +``` + +## Infinite Queries + +Use `.key` and `.fetcher` methods to configure `useSWRInfinite` for infinite queries: + +```ts +import useSWRInfinite from 'swr/infinite' + +const { data, error, isLoading, size, setSize } = useSWRInfinite( + (index, previousPageData) => { + if (previousPageData && !previousPageData.nextCursor) { + return null // reached the end + } + + return orpc.planet.list.key({ input: { cursor: previousPageData?.nextCursor } }) + }, + orpc.planet.list.fetcher({ context: { cache: true } }), // Provide client context if needed +) +``` + +## Subscriptions + +Use `.key` and `.subscriber` methods to configure `useSWRSubscription` to subscribe to an [AsyncIteratorObject](/docs/async-iterator-object): + +```ts +import useSWRSubscription from 'swr/subscription' + +const { data, error } = useSWRSubscription( + orpc.streamed.key({ input: { id: 3 } }), + orpc.streamed.subscriber({ context: { cache: true }, maxChunks: 10 }), // Provide client context if needed +) +``` + +Use `.liveSubscriber` to subscribe to the latest events without chunking: + +```ts +import useSWRSubscription from 'swr/subscription' + +const { data, error } = useSWRSubscription( + orpc.streamed.key({ input: { id: 3 } }), + orpc.streamed.liveSubscriber({ context: { cache: true } }), // Provide client context if needed +) +``` + +## Mutations + +Use `.key` and `.mutator` methods to configure `useSWRMutation` for mutations with automatic revalidation on success: + +```ts +import useSWRMutation from 'swr/mutation' + +const { trigger, isMutating } = useSWRMutation( + orpc.planet.list.key(), + orpc.planet.create.mutator({ context: { cache: true } }), // Provide client context if needed +) + +trigger({ name: 'New Planet' }) // auto revalidate orpc.planet.list.key() on success +``` + +## Manual Revalidation + +Use `.matcher` to invalidate data manually: + +```ts +import { mutate } from 'swr' + +mutate(orpc.matcher()) // invalidate all orpc data +mutate(orpc.planet.matcher()) // invalidate all planet data +mutate(orpc.planet.find.matcher({ input: { id: 123 }, strategy: 'exact' })) // invalidate specific planet data +``` + +## Calling Clients + +Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. + +```ts +const planet = await orpc.planet.find.call({ id: 123 }) +``` + +## Operation Context + +When clients are invoked through the SWR integration, an **operation context** is automatically added to the [client context](/docs/client/rpc-link#using-client-context). This context can be used to configure the request behavior, like setting the HTTP method. + +```ts +import { + SWR_OPERATION_CONTEXT_SYMBOL, + SWROperationContext, +} from '@orpc/swr' + +interface ClientContext extends SWROperationContext { +} + +const GET_OPERATION_TYPE = new Set(['fetcher', 'subscriber', 'liveSubscriber']) + +const link = new RPCLink({ + method: ({ context }, path) => { + const operationType = context[SWR_OPERATION_CONTEXT_SYMBOL]?.type + + if (operationType && GET_OPERATION_TYPE.has(operationType)) { + return 'GET' + } + + return 'POST' + }, +}) +``` diff --git a/packages/ai-sdk/README.md b/packages/ai-sdk/README.md index 459e7de60..a62a73306 100644 --- a/packages/ai-sdk/README.md +++ b/packages/ai-sdk/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/arktype/README.md b/packages/arktype/README.md index b45ff9e9f..882660b69 100644 --- a/packages/arktype/README.md +++ b/packages/arktype/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/bun/README.md b/packages/bun/README.md index 96ff840a7..7d9e0b719 100644 --- a/packages/bun/README.md +++ b/packages/bun/README.md @@ -49,6 +49,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/client/README.md b/packages/client/README.md index dc7a2c230..f99660aa5 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index b89ee75df..f64589fdc 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/contract/README.md b/packages/contract/README.md index eba7d1cc3..8b8594e50 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/effect/README.md b/packages/effect/README.md index 122799365..5bfb63bd5 100644 --- a/packages/effect/README.md +++ b/packages/effect/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/evlog/README.md b/packages/evlog/README.md index ec42d1a10..ba01a8b6f 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/json-schema/README.md b/packages/json-schema/README.md index 405f75889..7a4da3313 100644 --- a/packages/json-schema/README.md +++ b/packages/json-schema/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/nest/README.md b/packages/nest/README.md index 3fba87e46..d7c30214d 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/next/README.md b/packages/next/README.md index 0e0c4b7e8..52e9f2f4c 100644 --- a/packages/next/README.md +++ b/packages/next/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/openapi/README.md b/packages/openapi/README.md index a6310065d..029f318a5 100644 --- a/packages/openapi/README.md +++ b/packages/openapi/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/opentelemetry/README.md b/packages/opentelemetry/README.md index 70f261235..c448ccbb9 100644 --- a/packages/opentelemetry/README.md +++ b/packages/opentelemetry/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/pinia-colada/README.md b/packages/pinia-colada/README.md index 93b577342..af7b84e5e 100644 --- a/packages/pinia-colada/README.md +++ b/packages/pinia-colada/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/pino/README.md b/packages/pino/README.md index fc189d4d7..b3c871081 100644 --- a/packages/pino/README.md +++ b/packages/pino/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/publisher/README.md b/packages/publisher/README.md index 55c916cd8..0ad90c7b5 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 012d54d29..ede390f87 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/server/README.md b/packages/server/README.md index 901bf7228..9b0f87ce4 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/shared/README.md b/packages/shared/README.md index e9e44a02a..8c8a4bf4a 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/swr/.gitignore b/packages/swr/.gitignore new file mode 100644 index 000000000..f3620b55e --- /dev/null +++ b/packages/swr/.gitignore @@ -0,0 +1,26 @@ +# Hidden folders and files +.* +!.gitignore +!.*.example + +# Common generated folders +logs/ +node_modules/ +out/ +dist/ +dist-ssr/ +build/ +coverage/ +temp/ + +# Common generated files +*.log +*.log.* +*.tsbuildinfo +*.vitest-temp.json +vite.config.ts.timestamp-* +vitest.config.ts.timestamp-* + +# Common manual ignore files +*.local +*.pem \ No newline at end of file diff --git a/packages/swr/README.md b/packages/swr/README.md new file mode 100644 index 000000000..c8ed8ccbd --- /dev/null +++ b/packages/swr/README.md @@ -0,0 +1,199 @@ +

oRPC - Typesafe APIs Made Simple 🪄

+ +
+ + codecov + + + weekly downloads + + + CodSpeed + + + MIT License + + + Discord + + + Ask DeepWiki + +
+ +## Documentation + +You can read the documentation [here](https://orpc.dev). + +## Packages + +**Core** + +- [@orpc/contract](https://www.npmjs.com/package/@orpc/contract): Define API contract as the single source of truth. +- [@orpc/server](https://www.npmjs.com/package/@orpc/server): Build APIs or implement contracts. +- [@orpc/client](https://www.npmjs.com/package/@orpc/client): Consume APIs with end-to-end type safety. +- [@orpc/openapi](https://www.npmjs.com/package/@orpc/openapi): Add OpenAPI compatibility to APIs. + +**Schema validation** + +- [@orpc/zod](https://www.npmjs.com/package/@orpc/zod): Integrate with [Zod](https://zod.dev/). +- [@orpc/valibot](https://www.npmjs.com/package/@orpc/valibot): Integrate with [Valibot](https://valibot.dev/). +- [@orpc/arktype](https://www.npmjs.com/package/@orpc/arktype): Integrate with [ArkType](https://arktype.io/). + +**Built-in features** + +- [@orpc/publisher](https://www.npmjs.com/package/@orpc/publisher): Pub/Sub with memory, Redis, and Upstash adapters. +- [@orpc/ratelimit](https://www.npmjs.com/package/@orpc/ratelimit): Rate limiting with memory, Redis, and Upstash adapters. +- [@orpc/json-schema](https://www.npmjs.com/package/@orpc/json-schema): Smart coercion for OpenAPI requests. + +**Framework & ecosystem integrations** + +- [@orpc/next](https://www.npmjs.com/package/@orpc/next): Integrate with [Next.js Server Functions](https://nextjs.org/docs/app/getting-started/mutating-data). +- [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. +- [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). +- [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). +- [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). +- [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). +- [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). +- [@orpc/cloudflare](https://www.npmjs.com/package/@orpc/cloudflare): Adapters for [Cloudflare's RateLimit and Durable Objects](https://developers.cloudflare.com/workers/). +- [@orpc/trpc](https://www.npmjs.com/package/@orpc/trpc): Reuse existing [tRPC](https://trpc.io/) routers within oRPC. + +**Observability** + +- [@orpc/opentelemetry](https://www.npmjs.com/package/@orpc/opentelemetry): Integrate with [OpenTelemetry](https://opentelemetry.io/) for distributed tracing. +- [@orpc/pino](https://www.npmjs.com/package/@orpc/pino): Integrate with [Pino](https://getpino.io/) for logging. +- [@orpc/evlog](https://www.npmjs.com/package/@orpc/evlog): Integrate with [Evlog](https://evlog.dev/) for logging. + +## Sponsors + +Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going here: [GitHub Sponsors](https://github.com/sponsors/dinwwwh). Every bit helps! 🚀 + +### 🏆 Platinum Sponsor + + + + + +
ScreenshotOne.com
ScreenshotOne.com
+ +### 🥈 Silver Sponsor + + + + + +
村上さん
村上さん
+ +### Generous Sponsors + + + + + +
LN Markets
LN Markets
+ +### Sponsors + + + + + + + + + + + + + + + + + + + + + + +
Reece McDonald
Reece McDonald
nk
nk
supastarter
supastarter
Dexter Miguel
Dexter Miguel
herrfugbaum
herrfugbaum
Ryota Murakami
Ryota Murakami
David Cramer
David Cramer
Valerii Petryniak
Valerii Petryniak
Valerii Strilets
Valerii Strilets
Kyle Mistele
Kyle Mistele
Ryan Vogel
Ryan Vogel
christ12938
christ12938
Ryan Soderberg
Ryan Soderberg
shota
shota
+ +### Backers + + + + + + + + + + + + + + + + + + + + + + + + + +
David Walsh
David Walsh
Nicholas
Nicholas
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
Alex
Alex
Andrey Gubanov
Andrey Gubanov
+ +### Past Sponsors + +

+ Maxie + Stijn Timmer + あわわわとーにゅ + Zuplo + motopods + Francisco Hermida + Théo LUDWIG + Abhay Ramesh + shr.ink oü + 0x4e32 + Ryuz + happyboy + yicchi + Saksham + Roman Hrynevych + rokitg + Omar Khatib + Yu-Sabo + Bapusaheb Patil + grim + Nelson Lai + Lê Cao Nguyên + Robert Soriano + Andrew Peters + SKostyukovich + Peter Adam + Fabworks + Novak Antonijevic + Laduni Estu Syalwa + Chen, Zhi-Yuan + Illarion Koperski + Anees Iqbal + Sefa Eyeoglu + natt + Adam Tkaczyk + plancraft +

+ +## 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/swr/package.json b/packages/swr/package.json new file mode 100644 index 000000000..044450737 --- /dev/null +++ b/packages/swr/package.json @@ -0,0 +1,50 @@ +{ + "name": "@orpc/swr", + "type": "module", + "version": "2.0.0-beta.19", + "license": "MIT", + "homepage": "https://orpc.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/middleapi/orpc.git", + "directory": "packages/swr" + }, + "keywords": [ + "orpc", + "react", + "swr" + ], + "sideEffects": false, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + } + }, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "unbuild", + "type:check": "tsc -b" + }, + "peerDependencies": { + "swr": ">=2.4.2" + }, + "dependencies": { + "@orpc/client": "workspace:*", + "@orpc/shared": "workspace:*" + }, + "devDependencies": { + "swr": "^2.4.2", + "zod": "^4.4.3" + } +} diff --git a/packages/swr/src/index.test.ts b/packages/swr/src/index.test.ts new file mode 100644 index 000000000..5bc06787c --- /dev/null +++ b/packages/swr/src/index.test.ts @@ -0,0 +1,9 @@ +it('exports createSWRUtils, SWR_OPERATION_CONTEXT_SYMBOL', async () => { + await expect(import('./index')).resolves.toMatchObject({ + createSWRUtils: expect.any(Function), + createRouterUtils: expect.any(Function), + generateOperationKey: expect.any(Function), + isSubsetOf: expect.any(Function), + SWR_OPERATION_CONTEXT_SYMBOL: expect.any(Symbol), + }) +}) diff --git a/packages/swr/src/index.ts b/packages/swr/src/index.ts new file mode 100644 index 000000000..5f752068d --- /dev/null +++ b/packages/swr/src/index.ts @@ -0,0 +1,13 @@ +export * from './key' +export * from './procedure-utils' +export * from './router-utils' +export { + createRouterUtils as createSWRUtils, +} from './router-utils' +export * from './shared-utils' +export * from './types' +export { + OPERATION_CONTEXT_SYMBOL as SWR_OPERATION_CONTEXT_SYMBOL, + type OperationContext as SWROperationContext, +} from './types' +export * from './utils' diff --git a/packages/swr/src/key.test.ts b/packages/swr/src/key.test.ts new file mode 100644 index 000000000..8a5aa6804 --- /dev/null +++ b/packages/swr/src/key.test.ts @@ -0,0 +1,13 @@ +import { generateOperationKey } from './key' + +describe('generateOperationKey', () => { + it('without prefix', () => { + expect(generateOperationKey(['ping'])).toEqual([['ping'], {}]) + expect(generateOperationKey(['ping'], { input: { search: '__search__' } })).toEqual([['ping'], { input: { search: '__search__' } }]) + }) + + it('with prefix', () => { + expect(generateOperationKey(['ping'], { prefix: '__prefix__' })).toEqual(['__prefix__', ['ping'], {}]) + expect(generateOperationKey(['ping'], { prefix: '__prefix__', input: { search: '__search__' } })).toEqual(['__prefix__', ['ping'], { input: { search: '__search__' } }]) + }) +}) diff --git a/packages/swr/src/key.ts b/packages/swr/src/key.ts new file mode 100644 index 000000000..d83fb353b --- /dev/null +++ b/packages/swr/src/key.ts @@ -0,0 +1,35 @@ +import type { PartialDeep } from '@orpc/shared' + +export interface OperationKeyPrefixOptions { + /** + * Prepended as the first element of the key when present. + * Use this to avoid key conflicts when multiple router utils share the same client. + */ + prefix?: string +} + +export type OperationKeyOptions = OperationKeyPrefixOptions & { + input?: PartialDeep +} + +export type OperationKey + = | [path: string[], options: Omit, 'prefix'>] + | [prefix: string, path: string[], options: Omit, 'prefix'>] + +/** + * Generate a **full matching** key for SWR operations. + * + * @see {@link https://orpc.dev/docs/integrations/swr#data-fetching SWR Key Docs} + */ +export function generateOperationKey( + path: string[], + options: OperationKeyOptions = {}, +): OperationKey { + return [ + ...options.prefix !== undefined ? [options.prefix] : [], + path, + { + ...options.input !== undefined ? { input: options.input } : {}, + }, + ] as OperationKey +} diff --git a/packages/swr/src/procedure-utils.test-d.ts b/packages/swr/src/procedure-utils.test-d.ts new file mode 100644 index 000000000..1a77ba19b --- /dev/null +++ b/packages/swr/src/procedure-utils.test-d.ts @@ -0,0 +1,221 @@ +import type { Client, ORPCError } from '@orpc/client' +import type { Public } from '@orpc/shared' +import type { OperationKey } from './key' +import type { ProcedureUtils } from './procedure-utils' +import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' +import useSWRMutation from 'swr/mutation' +import useSWRSubscription from 'swr/subscription' + +describe('procedureUtils', () => { + type UtilsInput = { search?: string, cursor?: number } | undefined + type UtilsOutput = { title: string }[] + type UtilsError = ORPCError<'BASE', { output: string }> | Error + const condition = {} as boolean + + const optionalUtils = {} as Public> + + const requiredUtils = {} as Public> + + const streamUtils = {} as Public, + UtilsError + >> + + it('.call', () => { + expectTypeOf(optionalUtils.call).toEqualTypeOf< + Client<{ batch?: boolean }, UtilsInput, UtilsOutput, UtilsError> + >() + }) + + describe('.key', () => { + it('should handle optional `input` correctly', () => { + optionalUtils.key() + optionalUtils.key({ }) + optionalUtils.key({ input: { search: 'search' } }) + }) + + it('should handle required `input` correctly', () => { + // @ts-expect-error - `input` is required + requiredUtils.key() + }) + + it('should infer types for `input` correctly', () => { + optionalUtils.key({ input: { cursor: 1 } }) + // @ts-expect-error - Should error on invalid input type + optionalUtils.key({ input: { cursor: 'invalid' } }) + // @ts-expect-error - Should error on non-existent input property + optionalUtils.key({ input: { cursor: 1, nonExistent: true } }) + + requiredUtils.key({ input: 'input' }) + // @ts-expect-error - Should error on invalid input type + requiredUtils.key({ input: 123 }) + }) + + it('return valid key', () => { + expectTypeOf(optionalUtils.key()).toEqualTypeOf>() + expectTypeOf(optionalUtils.key({ input: { search: 'search' } })).toEqualTypeOf>() + }) + }) + + describe('.fetcher & useSWR & useSWRInfinite', () => { + it('require matching key', () => { + useSWR(optionalUtils.key(), optionalUtils.fetcher()) + // FIXME: This should be an error, but SWR does not enforce key type + useSWR('invalid', optionalUtils.fetcher()) + + useSWRInfinite((index, previousPage) => { + // FIXME: This should be typed + // expectTypeOf(previousPage).toEqualTypeOf() + + return optionalUtils.key() + }, optionalUtils.fetcher()) + // FIXME: This should be an error, but SWR does not enforce key type + useSWRInfinite(() => 'invalid', optionalUtils.fetcher()) + }) + + it('allow use `null` as key', () => { + useSWR(condition ? null : optionalUtils.key(), optionalUtils.fetcher()) + useSWRInfinite(() => condition ? null : optionalUtils.key(), optionalUtils.fetcher()) + }) + + it('should infer types for `context` correctly', () => { + optionalUtils.fetcher() + optionalUtils.fetcher({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + optionalUtils.fetcher({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + optionalUtils.fetcher({ context: { batch: 'invalid' } }) + + // @ts-expect-error - should require provided context if contains non-optional fields + requiredUtils.fetcher() + requiredUtils.fetcher({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + requiredUtils.fetcher({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + requiredUtils.fetcher({ context: { batch: 'invalid' } }) + }) + + it('should infer types for `output` and `error` correctly', () => { + const swr = useSWR(optionalUtils.key(), optionalUtils.fetcher()) + expectTypeOf(swr.data).toEqualTypeOf() + // FIXME: this should be typed + // expectTypeOf(swr.error).toEqualTypeOf() + + const infinite = useSWRInfinite((index, previousPage) => optionalUtils.key(), optionalUtils.fetcher()) + expectTypeOf(infinite.data).toEqualTypeOf() + // FIXME: this should be typed + // expectTypeOf(infinite.error).toEqualTypeOf() + }) + }) + + describe('.subscriber & useSWRSubscription', () => { + it('require matching key', () => { + useSWRSubscription(optionalUtils.key(), optionalUtils.subscriber()) + // @ts-expect-error - Should error on invalid key type + useSWRSubscription('invalid', optionalUtils.subscriber()) + }) + + it('allow use `null` as key', () => { + useSWRSubscription(condition ? null : optionalUtils.key(), optionalUtils.subscriber()) + }) + + it('should infer types for `context` correctly', () => { + optionalUtils.subscriber() + optionalUtils.subscriber({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + optionalUtils.subscriber({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + optionalUtils.subscriber({ context: { batch: 'invalid' } }) + + // @ts-expect-error - should require provided context if contains non-optional fields + requiredUtils.subscriber() + requiredUtils.subscriber({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + requiredUtils.subscriber({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + requiredUtils.subscriber({ context: { batch: 'invalid' } }) + }) + + it('should infer types for `output` and `error` correctly', () => { + const subscription = useSWRSubscription(streamUtils.key(), streamUtils.subscriber({ maxChunks: 5 })) + expectTypeOf(subscription.data).toEqualTypeOf() + expectTypeOf(subscription.error).toEqualTypeOf() + }) + }) + + describe('.liveSubscriber & useSWRSubscription', () => { + it('require matching key', () => { + useSWRSubscription(optionalUtils.key(), optionalUtils.liveSubscriber()) + // @ts-expect-error - Should error on invalid key type + useSWRSubscription('invalid', optionalUtils.liveSubscriber()) + }) + + it('allow use `null` as key', () => { + useSWRSubscription(condition ? null : optionalUtils.key(), optionalUtils.liveSubscriber()) + }) + + it('should infer types for `context` correctly', () => { + optionalUtils.liveSubscriber() + optionalUtils.liveSubscriber({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + optionalUtils.liveSubscriber({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + optionalUtils.liveSubscriber({ context: { batch: 'invalid' } }) + + // @ts-expect-error - should require provided context if contains non-optional fields + requiredUtils.liveSubscriber() + requiredUtils.liveSubscriber({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + requiredUtils.liveSubscriber({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + requiredUtils.liveSubscriber({ context: { batch: 'invalid' } }) + }) + + it('should infer types for `output` and `error` correctly', () => { + const subscription = useSWRSubscription(streamUtils.key(), streamUtils.liveSubscriber()) + expectTypeOf(subscription.data).toEqualTypeOf() + expectTypeOf(subscription.error).toEqualTypeOf() + }) + }) + + describe('.mutator & useSWRMutation', () => { + it('should infer types for `context` correctly', () => { + optionalUtils.mutator() + optionalUtils.mutator({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + optionalUtils.mutator({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + optionalUtils.mutator({ context: { batch: 'invalid' } }) + + // @ts-expect-error - should require provided context if contains non-optional fields + requiredUtils.mutator() + requiredUtils.mutator({ context: { batch: true } }) + // @ts-expect-error - Should error on non-existent context property + requiredUtils.mutator({ context: { batch: true, nonExistent: true } }) + // @ts-expect-error - Should error on invalid context type + requiredUtils.mutator({ context: { batch: 'invalid' } }) + }) + + it('should infer types for `output` & `input` & `error` correctly', () => { + const mutation = useSWRMutation('some-key', optionalUtils.mutator()) + + expectTypeOf[0]>().toEqualTypeOf() + expectTypeOf(mutation.data).toEqualTypeOf() + // FIXME: this should be typed + // expectTypeOf(mutation.error).toEqualTypeOf() + }) + }) +}) diff --git a/packages/swr/src/procedure-utils.test.ts b/packages/swr/src/procedure-utils.test.ts new file mode 100644 index 000000000..cdc1e07bc --- /dev/null +++ b/packages/swr/src/procedure-utils.test.ts @@ -0,0 +1,326 @@ +import type { OperationKey } from './key' +import { ProcedureUtils } from './procedure-utils' +import { OPERATION_CONTEXT_SYMBOL } from './types' + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('procedureUtils', () => { + const client = vi.fn() + const utils = new ProcedureUtils(['ping'], client, {}) + + const key: OperationKey = [['ping'], { input: { search: '__search__' } }] + + it('.call', () => { + expect(utils.call).toBe(client) + }) + + it('.key', () => { + expect(utils.key()).toEqual([['ping'], {}]) + expect(utils.key({ input: { search: '__search__' } })).toEqual([['ping'], { input: { search: '__search__' } }]) + }) + + it('.key with prefix', () => { + const prefixedUtils = new ProcedureUtils(['ping'], client, { prefix: '__prefix__' }) + + expect(prefixedUtils.key()).toEqual(['__prefix__', ['ping'], {}]) + expect(prefixedUtils.key({ input: { search: '__search__' } })).toEqual(['__prefix__', ['ping'], { input: { search: '__search__' } }]) + }) + + it('.fetcher', async () => { + client.mockResolvedValueOnce('__output__') + const fetcher = utils.fetcher({ context: { batch: true } }) + + await expect(fetcher(key)).resolves.toEqual('__output__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'fetcher' } } }) + }) + + it('.fetcher with prefixed key', async () => { + client.mockResolvedValueOnce('__output__') + const fetcher = utils.fetcher({ context: { batch: true } }) + + const prefixedKey: OperationKey = ['__prefix__', ['ping'], { input: { search: '__search__' } }] + + await expect(fetcher(prefixedKey)).resolves.toEqual('__output__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key: prefixedKey, type: 'fetcher' } } }) + }) + + describe('.subscriber', async () => { + it('on success', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2 }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + + expect(unsubscribe).toBeInstanceOf(Function) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'subscriber' } }, signal: expect.any(AbortSignal) }) + + expect(next).toHaveBeenCalledTimes(4) + expect(next).toHaveBeenCalledWith(undefined, expect.any(Function)) + + expect(next).toHaveBeenNthCalledWith(1, undefined, undefined) // reset mode + expect(next.mock.calls[1]![1](undefined)).toEqual(['__event__1']) + expect(next.mock.calls[2]![1](['1'])).toEqual(['1', '__event__2']) + // exceeds maxChunks, so it should only return the last 2 events + expect(next.mock.calls[3]![1](['1', '2'])).toEqual(['2', '__event__3']) + }) + + it('on success refetchMode=append', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2, refetchMode: 'append' }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + + expect(unsubscribe).toBeInstanceOf(Function) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'subscriber' } }, signal: expect.any(AbortSignal) }) + + expect(next).toHaveBeenCalledTimes(3) + expect(next).toHaveBeenCalledWith(undefined, expect.any(Function)) + + expect(next.mock.calls[0]![1](undefined)).toEqual(['__event__1']) + expect(next.mock.calls[1]![1](['1'])).toEqual(['1', '__event__2']) + // exceeds maxChunks, so it should only return the last 2 events + expect(next.mock.calls[2]![1](['1', '2'])).toEqual(['2', '__event__3']) + }) + + it('on success refetchMode=replace with previous data', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2, refetchMode: 'replace' }) + + // simulate SWR: updaters are invoked synchronously with the current data + let data: unknown = ['__previous__'] + const next = vi.fn((error, update?) => { + if (error === undefined) { + data = typeof update === 'function' ? update(data) : update + } + }) + + subscriber(key, { next }) + + await new Promise(resolve => setTimeout(resolve, 20)) + + // one probe call + one final replace call, no updates during the stream + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenNthCalledWith(1, undefined, expect.any(Function)) + // exceeds maxChunks, so it should only keep the last 2 events + expect(next).toHaveBeenNthCalledWith(2, undefined, ['__event__2', '__event__3']) + expect(data).toEqual(['__event__2', '__event__3']) + }) + + it('on success refetchMode=replace without previous data', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.subscriber({ context: { batch: true }, refetchMode: 'replace' }) + + // simulate SWR: updaters are invoked synchronously with the current data + let data: unknown + const next = vi.fn((error, update?) => { + if (error === undefined) { + data = typeof update === 'function' ? update(data) : update + } + }) + + subscriber(key, { next }) + + await new Promise(resolve => setTimeout(resolve, 20)) + + // one probe call + one update per event, streamed live like append mode + expect(next).toHaveBeenCalledTimes(4) + expect(data).toEqual(['__event__1', '__event__2', '__event__3']) + }) + + it('on unsubscribe', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 100)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2 }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + await new Promise(resolve => setTimeout(resolve, 10)) + unsubscribe() + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'subscriber' } }, signal: expect.any(AbortSignal) }) + expect(client.mock.calls[0]![1].signal.aborted).toBe(true) + }) + + it('on error while yielding', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error('__error__') + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2 }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + + expect(unsubscribe).toBeInstanceOf(Function) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(next).toHaveBeenCalledTimes(2) + expect(next).toHaveBeenCalledWith(new Error('__error__')) + }) + + it('on error after unsubscribe', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error('__error__') + }) + const subscriber = utils.subscriber({ context: { batch: true }, maxChunks: 2 }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + unsubscribe() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(next).toHaveBeenCalledTimes(1) + }) + + it('on non-AsyncIteratorObject output', async () => { + client.mockResolvedValueOnce('__output__') + const subscriber = utils.subscriber({ context: { batch: true } }) + + const next = vi.fn() + subscriber(key, { next }) + + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(new TypeError('.subscriber requires an AsyncIteratorObject output')) + }) + }) + + describe('.liveSubscriber', async () => { + it('on success', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.liveSubscriber({ context: { batch: true } }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + + expect(unsubscribe).toBeInstanceOf(Function) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'liveSubscriber' } }, signal: expect.any(AbortSignal) }) + + expect(next).toHaveBeenCalledTimes(3) + expect(next).toHaveBeenNthCalledWith(1, undefined, '__event__1') + expect(next).toHaveBeenNthCalledWith(2, undefined, '__event__2') + expect(next).toHaveBeenNthCalledWith(3, undefined, '__event__3') + }) + + it('on unsubscribe', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + yield '__event__1' + yield '__event__2' + yield '__event__3' + }) + const subscriber = utils.liveSubscriber({ context: { batch: true } }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + await new Promise(resolve => setTimeout(resolve, 20)) + unsubscribe() + + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key, type: 'liveSubscriber' } }, signal: expect.any(AbortSignal) }) + expect(client.mock.calls[0]![1].signal.aborted).toBe(true) + }) + + it('on error while yielding', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error('__error__') + }) + const subscriber = utils.liveSubscriber({ context: { batch: true } }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + + expect(unsubscribe).toBeInstanceOf(Function) + + await new Promise(resolve => setTimeout(resolve, 20)) + + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(new Error('__error__')) + }) + + it('on error after unsubscribe', async () => { + client.mockImplementationOnce(async function* () { + await new Promise(resolve => setTimeout(resolve, 10)) + throw new Error('__error__') + }) + const subscriber = utils.liveSubscriber({ context: { batch: true } }) + + const next = vi.fn() + const unsubscribe = subscriber(key, { next }) + unsubscribe() + await new Promise(resolve => setTimeout(resolve, 20)) + expect(next).toHaveBeenCalledTimes(0) + }) + + it('on non-AsyncIteratorObject output', async () => { + client.mockResolvedValueOnce('__output__') + const subscriber = utils.liveSubscriber({ context: { batch: true } }) + + const next = vi.fn() + subscriber(key, { next }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(next).toHaveBeenCalledTimes(1) + expect(next).toHaveBeenCalledWith(new TypeError('.liveSubscriber requires an AsyncIteratorObject output')) + }) + }) + + it('.mutator', async () => { + client.mockResolvedValueOnce('__output__') + const mutator = utils.mutator({ context: { batch: true } }) + + await expect(mutator('key', { arg: { search: '__search__' } })).resolves.toEqual('__output__') + expect(client).toHaveBeenCalledTimes(1) + expect(client).toHaveBeenCalledWith({ search: '__search__' }, { context: { batch: true, [OPERATION_CONTEXT_SYMBOL]: { key: 'key', type: 'mutator' } } }) + }) +}) diff --git a/packages/swr/src/procedure-utils.ts b/packages/swr/src/procedure-utils.ts new file mode 100644 index 000000000..17a0742e3 --- /dev/null +++ b/packages/swr/src/procedure-utils.ts @@ -0,0 +1,243 @@ +import type { Client, ClientContext } from '@orpc/client' +import type { MaybeOptionalOptions } from '@orpc/shared' +import type { OperationKey, OperationKeyOptions, OperationKeyPrefixOptions } from './key' +import type { + Fetcher, + FetcherOptions, + InferLiveSubscriberOutput, + InferSubscriberOutput, + KeyOptions, + Mutator, + MutatorOptions, + OperationContext, + Subscriber, + SubscriberOptions, +} from './types' +import { isAsyncIteratorObject, resolveMaybeOptionalOptions } from '@orpc/shared' +import { generateOperationKey } from './key' +import { SharedUtils } from './shared-utils' +import { OPERATION_CONTEXT_SYMBOL } from './types' + +export interface ProcedureUtilsOptions extends OperationKeyPrefixOptions { +} + +export class ProcedureUtils extends SharedUtils { + /** + * Calling corresponding procedure client + * + * @see {@link https://orpc.dev/docs/integrations/swr#calling-clients SWR Calling Procedure Client Docs} + */ + call: Client + + constructor( + path: string[], + client: Client, + options: ProcedureUtilsOptions = {}, + ) { + super(path, options) + this.call = client + } + + /** + * Generate a **full matching** key for SWR operations. + * + * @see {@link https://orpc.dev/docs/integrations/swr#data-fetching SWR Key Docs} + */ + key( + ...rest: MaybeOptionalOptions> + ): OperationKey { + const options = resolveMaybeOptionalOptions(rest) + + return generateOperationKey(this.path, { ...options, prefix: this.options.prefix } as OperationKeyOptions) + } + + /** + * Generate a fetcher function for use with useSWR, useSWRInfinite, and other SWR hooks. + * + * @see {@link https://orpc.dev/docs/integrations/swr#data-fetching SWR Data Fetching Docs} + */ + fetcher( + ...rest: MaybeOptionalOptions> + ): Fetcher { + const options = resolveMaybeOptionalOptions(rest) + + return async (key) => { + /** + * Input can be omitted from the key when it is undefined, + * so we can safely cast it to TInput with an undefined fallback. + */ + const { input } = key[key.length - 1] as { input: TInput } + + return this.call(input, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { key, type: 'fetcher' }, + ...options.context, + } satisfies OperationContext as any, + }) + } + } + + /** + * Generate a subscriber function that subscribes to an [AsyncIteratorObject](https://orpc.dev/docs/async-iterator-object) for use with useSWRSubscription, etc. + * + * @see {@link https://orpc.dev/docs/integrations/swr#subscriptions SWR Subscriptions Docs} + */ + subscriber( + ...rest: MaybeOptionalOptions> + ): Subscriber, TError> { + const { maxChunks, refetchMode = 'reset', ...options } = resolveMaybeOptionalOptions(rest) + + return (key, { next }) => { + const controller = new AbortController() + + void (async () => { + try { + /** + * Input can be omitted from the key when it is undefined, + * so we can safely cast it to TInput with an undefined fallback. + */ + const { input } = key[key.length - 1] as { input: TInput } + + const iterator = await this.call(input, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { key, type: 'subscriber' }, + ...options.context, + } satisfies OperationContext as any, + signal: controller.signal, + }) + + if (!isAsyncIteratorObject(iterator)) { + throw new TypeError('.subscriber requires an AsyncIteratorObject output') + } + + let hasPreviousData = false + + if (refetchMode === 'reset') { + next(undefined, undefined) + } + else if (refetchMode === 'replace') { + /** + * The updater is invoked synchronously with the current data, + * so we can use it to detect whether previous data exists. + */ + next(undefined, (old) => { + hasPreviousData = old !== undefined + return old as InferSubscriberOutput + }) + } + + const shouldUpdateDataDuringStream = refetchMode !== 'replace' || !hasPreviousData + let buffer: unknown[] = [] + + for await (const event of iterator) { + if (shouldUpdateDataDuringStream) { + next(undefined, (old) => { + const newData = Array.isArray(old) ? [...old, event] : [event] + + if (typeof maxChunks === 'number' && newData.length > maxChunks) { + return newData.slice(newData.length - maxChunks) as InferSubscriberOutput + } + + return newData as InferSubscriberOutput + }) + } + else { + buffer = [...buffer, event] + + if (typeof maxChunks === 'number' && buffer.length > maxChunks) { + buffer = buffer.slice(buffer.length - maxChunks) + } + } + } + + if (!shouldUpdateDataDuringStream) { + next(undefined, buffer as InferSubscriberOutput) + } + } + catch (error) { + /** + * Only report the error if the controller is not aborted. + */ + if (!controller.signal.aborted) { + next(error as TError) + } + } + })() + + return () => { + controller.abort() + } + } + } + + /** + * Generate a live subscriber that subscribes to the latest events from an [AsyncIteratorObject](https://orpc.dev/docs/async-iterator-object) for use with useSWRSubscription, etc. + * + * @see {@link https://orpc.dev/docs/integrations/swr#subscriptions SWR Subscriptions Docs} + */ + liveSubscriber( + ...rest: MaybeOptionalOptions> + ): Subscriber, TError> { + const options = resolveMaybeOptionalOptions(rest) + + return (key, { next }) => { + const controller = new AbortController() + + void (async () => { + try { + /** + * Input can be omitted from the key when it is undefined, + * so we can safely cast it to TInput with an undefined fallback. + */ + const { input } = key[key.length - 1] as { input: TInput } + + const iterator = await this.call(input, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { key, type: 'liveSubscriber' }, + ...options.context, + } satisfies OperationContext as any, + signal: controller.signal, + }) + + if (!isAsyncIteratorObject(iterator)) { + throw new TypeError('.liveSubscriber requires an AsyncIteratorObject output') + } + + for await (const event of iterator) { + next(undefined, event as InferLiveSubscriberOutput) + } + } + catch (error) { + /** + * Only report the error if the controller is not aborted. + */ + if (!controller.signal.aborted) { + next(error as TError) + } + } + })() + + return () => { + controller.abort() + } + } + } + + /** + * Generate a mutator function for use with useSWRMutation, etc. + * + * @see {@link https://orpc.dev/docs/integrations/swr#mutations SWR Mutations Docs} + */ + mutator( + ...rest: MaybeOptionalOptions> + ): Mutator { + const options = resolveMaybeOptionalOptions(rest) + + return (key, { arg }) => this.call(arg, { + context: { + [OPERATION_CONTEXT_SYMBOL]: { key, type: 'mutator' }, + ...options.context, + } satisfies OperationContext as any, + }) + } +} diff --git a/packages/swr/src/router-utils.test-d.ts b/packages/swr/src/router-utils.test-d.ts new file mode 100644 index 000000000..73e1a0dfc --- /dev/null +++ b/packages/swr/src/router-utils.test-d.ts @@ -0,0 +1,64 @@ +import type { ORPCErrorFromErrorMap } from '@orpc/contract' +import type { RouterClient } from '@orpc/server' +import type { Public } from '@orpc/shared' +import type { ProcedureUtils } from './procedure-utils' +import type { RouterUtils } from './router-utils' +import type { SharedUtils } from './shared-utils' +import { os } from '@orpc/server' +import z from 'zod' +import { createRouterUtils } from './router-utils' + +const inputSchema = z.object({ input: z.number().transform(n => `${n}`) }) +const outputSchema = z.object({ output: z.number().transform(n => `${n}`) }) +const unknownSchema = z.unknown() + +const baseErrorMap = { + BASE: { + data: outputSchema, + }, + OVERRIDE: {}, +} + +const ping = os.input(inputSchema).output(outputSchema).errors(baseErrorMap).handler(() => ({ output: 1 })) +const pong = os.input(unknownSchema).output(unknownSchema).handler(() => 'pong') + +const router = { + ping, + pong, + nested: os.router({ + ping, + pong, + }), +} + +it('routerUtils', () => { + const utils = {} as RouterUtils> + + expectTypeOf(utils).toExtend>>() + expectTypeOf(utils.nested).toExtend>>() + + expectTypeOf(utils.ping).toExtend>>() + expectTypeOf(utils.nested.ping).toExtend>>() + + expectTypeOf(utils.ping).toExtend< + Public | Error>> + >() + expectTypeOf(utils.nested.ping).toExtend< + Public | Error>> + >() + + expectTypeOf(utils.pong).toExtend< + Public> + >() + expectTypeOf(utils.nested.pong).toExtend< + Public> + >() +}) + +it('createRouterUtils', () => { + const utils = createRouterUtils({} as RouterClient, { + prefix: '__prefix__', + }) + + expectTypeOf(utils).toEqualTypeOf>>() +}) diff --git a/packages/swr/src/router-utils.test.ts b/packages/swr/src/router-utils.test.ts new file mode 100644 index 000000000..bbb39ab41 --- /dev/null +++ b/packages/swr/src/router-utils.test.ts @@ -0,0 +1,105 @@ +import { ProcedureUtils } from './procedure-utils' +import { createRouterUtils } from './router-utils' + +vi.mock('./procedure-utils', async (importOriginal) => { + const { SharedUtils } = await import('./shared-utils') + + return { + ...await importOriginal(), + ProcedureUtils: vi.fn(class extends SharedUtils { + call = vi.fn() + override matcher = SharedUtils.prototype.matcher + key = vi.fn(() => ({ key: true })) + fetcher = vi.fn(() => ({ fetcher: true })) + + constructor(path: string[], _client: unknown, options: any = {}) { + super(path, options) + } + }), + } +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('createRouterUtils', () => { + const client = vi.fn() as any + client.key = vi.fn() // "key" mean client can handle when client and method is conflict + client.key.pong = vi.fn() + + it('creates nested procedure & shared utils', () => { + const utils = createRouterUtils(client, { + prefix: '__prefix__', + }) as any + + expect(ProcedureUtils).toHaveBeenCalledTimes(1) + expect(ProcedureUtils).toHaveBeenCalledWith([], client, { prefix: '__prefix__' }) + expect(utils.matcher()(['__prefix__', [], {}])).toBe(true) + expect(utils.matcher()([[], {}])).toBe(false) + expect(utils.fetcher()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.fetcher.mock.results[0]?.value) + + vi.clearAllMocks() + const keyUtils = utils.key + + expect(ProcedureUtils).toHaveBeenCalledTimes(1) + expect(ProcedureUtils).toHaveBeenCalledWith(['key'], client.key, { prefix: '__prefix__' }) + expect(keyUtils.matcher()(['__prefix__', ['key'], {}])).toBe(true) + expect(keyUtils.fetcher()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.fetcher.mock.results[0]?.value) + + vi.clearAllMocks() + const pongUtils = keyUtils.pong + + expect(ProcedureUtils).toHaveBeenCalledTimes(1) + expect(ProcedureUtils).toHaveBeenCalledWith(['key', 'pong'], client.key.pong, { prefix: '__prefix__' }) + expect(pongUtils.matcher()(['__prefix__', ['key', 'pong'], {}])).toBe(true) + expect(pongUtils.fetcher()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.fetcher.mock.results[0]?.value) + }) + + it('works with plain object routers', () => { + const router = { nested: { ping: vi.fn() } } as any + const utils = createRouterUtils(router) as any + + expect(ProcedureUtils).toHaveBeenCalledTimes(0) + expect(utils.fetcher).toBeUndefined() + expect(utils.matcher()([[], {}])).toBe(true) + + const nestedUtils = utils.nested + + expect(ProcedureUtils).toHaveBeenCalledTimes(0) + expect(nestedUtils.matcher()([['nested'], {}])).toBe(true) + + const pingUtils = nestedUtils.ping + + expect(ProcedureUtils).toHaveBeenCalledTimes(1) + expect(ProcedureUtils).toHaveBeenCalledWith(['nested', 'ping'], router.nested.ping, { prefix: undefined }) + expect(pingUtils.fetcher()).toBe(vi.mocked(ProcedureUtils).mock.results[0]?.value.fetcher.mock.results[0]?.value) + }) + + it('stops recursive on symbol', () => { + const utils = createRouterUtils(client) as any + expect(utils[Symbol.for('a')]).toBe(undefined) + }) + + it('stops recursive on symbol and RECURSIVE_CLIENT_UNWRAP_KEYS of conflicting method proxies', () => { + const utils = createRouterUtils(client) as any + const keyUtils = utils.key // "key" is both a util method and a nested client + + expect(keyUtils[Symbol.for('a')]).toBe(undefined) + expect(keyUtils.bind).toBeInstanceOf(Function) // resolved from the underlying method, not the nested utils + }) + + it('stops recursive on RECURSIVE_CLIENT_UNWRAP_KEYS', () => { + client.then = vi.fn() + + try { + const utils = createRouterUtils(client) as any + + expect(utils.then).toBe(undefined) + expect(ProcedureUtils).toHaveBeenCalledTimes(1) // only the root utils + } + finally { + delete client.then + } + }) +}) diff --git a/packages/swr/src/router-utils.ts b/packages/swr/src/router-utils.ts new file mode 100644 index 000000000..834d49d90 --- /dev/null +++ b/packages/swr/src/router-utils.ts @@ -0,0 +1,69 @@ +import type { AnyNestedClient, Client } from '@orpc/client' +import type { Public } from '@orpc/shared' +import type { OperationKeyPrefixOptions } from './key' +import { RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client' +import { bindMethods, get, getOrBind, isTypescriptObject, toArray } from '@orpc/shared' +import { ProcedureUtils } from './procedure-utils' +import { SharedUtils } from './shared-utils' + +export type RouterUtils + = T extends Client + ? Public> + : { + [K in keyof T]: T[K] extends AnyNestedClient ? RouterUtils : never + } & Public> + +export interface RouterUtilsOptions extends OperationKeyPrefixOptions { + /** + * Base path for all keys. + * + * @internal + */ + path?: string[] | undefined +} + +/** + * Create a swr router utils from a client. + * + * @info Both client-side and server-side clients are supported. + * @see {@link https://orpc.dev/docs/integrations/swr SWR Integration} + */ +export function createRouterUtils( + client: T, + options: RouterUtilsOptions = {}, +): RouterUtils { + const path = toArray(options.path) + + const utils = typeof client === 'function' + ? bindMethods(new ProcedureUtils(path, client, { prefix: options.prefix })) + : bindMethods(new SharedUtils(path, { prefix: options.prefix })) + + const recursive = new Proxy(utils, { + get(target, prop) { + const value = getOrBind(target, prop) + const nextClient = get(client, [prop]) + + if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop) || !isTypescriptObject(nextClient)) { + return value + } + + const nextUtils = createRouterUtils(nextClient as any, { ...options, path: [...path, prop] }) + + if (typeof value !== 'function') { + return nextUtils + } + + return new Proxy(value, { + get(target, prop) { + if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) { + return getOrBind(target, prop) + } + + return getOrBind(nextUtils, prop) + }, + }) + }, + }) + + return recursive as any +} diff --git a/packages/swr/src/shared-utils.test-d.ts b/packages/swr/src/shared-utils.test-d.ts new file mode 100644 index 000000000..8d466ba97 --- /dev/null +++ b/packages/swr/src/shared-utils.test-d.ts @@ -0,0 +1,36 @@ +import type { Public } from '@orpc/shared' +import type { SharedUtils } from './shared-utils' +import { mutate } from 'swr' + +describe('sharedUtils', () => { + const optionalUtils = {} as Public> + const requiredUtils = {} as Public> + + describe('.matcher & mutate', () => { + it('support partial input if strategy=partial', () => { + optionalUtils.matcher({ input: { nested: { value1: 'test' } } }) + optionalUtils.matcher({ input: { nested: {} } }) + optionalUtils.matcher({ input: { } }) + + requiredUtils.matcher({ input: { nested: { value1: 'test' } } }) + requiredUtils.matcher({ input: { nested: {} } }) + requiredUtils.matcher({ input: {} }) + }) + + it('require exact input if strategy=exact', () => { + optionalUtils.matcher({ strategy: 'exact' }) + optionalUtils.matcher({ strategy: 'exact', input: { nested: { value1: 'test' } } }) + + requiredUtils.matcher({ strategy: 'exact', input: { nested: { value1: 'test' } } }) + // @ts-expect-error - missing nested field + requiredUtils.matcher({ strategy: 'exact', input: { } }) + // @ts-expect-error - missing input field + requiredUtils.matcher({ strategy: 'exact' }) + }) + + it('can be used for mutating', () => { + mutate(optionalUtils.matcher()) + mutate(requiredUtils.matcher({ strategy: 'exact', input: { nested: { value1: 'test' } } })) + }) + }) +}) diff --git a/packages/swr/src/shared-utils.test.ts b/packages/swr/src/shared-utils.test.ts new file mode 100644 index 000000000..ad49e1f09 --- /dev/null +++ b/packages/swr/src/shared-utils.test.ts @@ -0,0 +1,33 @@ +import { SharedUtils } from './shared-utils' + +describe('sharedUtils', () => { + const utils = new SharedUtils(['test', 'path'], {}) + + describe('.matcher', () => { + it('strategy=partial', () => { + expect(utils.matcher()([['test', 'path'], { input: { value1: 'test' } }])).toBe(true) + expect(utils.matcher()([['test', 'path'], {}])).toBe(true) + expect(utils.matcher()([['invalid'], { input: { value1: 'test' } }])).toBe(false) + + expect(utils.matcher({ input: { value1: true } })([['test', 'path'], { input: { value1: true, value2: true } }])).toBe(true) + expect(utils.matcher({ input: { value1: true } })([['test', 'path'], { input: { value1: false, value2: true } }])).toBe(false) + }) + + it('strategy=exact', () => { + expect(utils.matcher({ strategy: 'exact', input: { value1: 'test' } })([['test', 'path'], { input: { value1: 'test' } }])).toBe(true) + expect(utils.matcher({ strategy: 'exact' })([['test', 'path'], { input: { value1: 'test' } }])).toBe(false) + expect(utils.matcher({ strategy: 'exact', input: { value1: 'test' } })([['invalid'], { input: { value1: 'test' } }])).toBe(false) + }) + + it('with prefix', () => { + const prefixedUtils = new SharedUtils(['test', 'path'], { prefix: '__prefix__' }) + + expect(prefixedUtils.matcher()(['__prefix__', ['test', 'path'], { input: { value1: 'test' } }])).toBe(true) + expect(prefixedUtils.matcher()([['test', 'path'], { input: { value1: 'test' } }])).toBe(false) + expect(prefixedUtils.matcher()(['__invalid__', ['test', 'path'], { input: { value1: 'test' } }])).toBe(false) + + expect(prefixedUtils.matcher({ strategy: 'exact', input: { value1: 'test' } })(['__prefix__', ['test', 'path'], { input: { value1: 'test' } }])).toBe(true) + expect(prefixedUtils.matcher({ strategy: 'exact' })(['__prefix__', ['test', 'path'], { input: { value1: 'test' } }])).toBe(false) + }) + }) +}) diff --git a/packages/swr/src/shared-utils.ts b/packages/swr/src/shared-utils.ts new file mode 100644 index 000000000..eab0f87f6 --- /dev/null +++ b/packages/swr/src/shared-utils.ts @@ -0,0 +1,38 @@ +import type { MaybeOptionalOptions, PartialDeep } from '@orpc/shared' +import type { OperationKeyPrefixOptions } from './key' +import type { Matcher, MatcherOptions, MatcherStrategy } from './types' +import { resolveMaybeOptionalOptions } from '@orpc/shared' +import { generateOperationKey } from './key' +import { isSubsetOf } from './utils' + +export class SharedUtils { + constructor( + protected readonly path: string[], + protected readonly options: OperationKeyPrefixOptions, + ) {} + + /** + * Generate a matcher function that returns `true` if the key matches the specified conditions. + * + * @see {@link https://orpc.dev/docs/integrations/swr#manual-revalidation SWR Manual Revalidation Docs} + */ + matcher( + ...rest: MaybeOptionalOptions> + ): Matcher { + const { input, strategy = 'partial' } = resolveMaybeOptionalOptions(rest) + + const expectedKey = generateOperationKey(this.path, { prefix: this.options.prefix, input: input as PartialDeep }) + + return (key) => { + if (!isSubsetOf(expectedKey, key)) { + return false + } + + if (strategy === 'exact' && !isSubsetOf(key, expectedKey)) { + return false + } + + return true + } + } +} diff --git a/packages/swr/src/types.ts b/packages/swr/src/types.ts new file mode 100644 index 000000000..c2568005b --- /dev/null +++ b/packages/swr/src/types.ts @@ -0,0 +1,69 @@ +import type { ClientContext } from '@orpc/client' +import type { PartialDeep } from '@orpc/shared' +import type { SWRSubscriptionOptions } from 'swr/subscription' +import type { OperationKey } from './key' + +export type InferSubscriberOutput = TOutput extends AsyncIterable ? U[] : never +export type InferLiveSubscriberOutput = TOutput extends AsyncIterable ? U : never + +export type OperationType = 'fetcher' | 'mutator' | 'subscriber' | 'liveSubscriber' + +export const OPERATION_CONTEXT_SYMBOL: unique symbol = Symbol.for('ORPC_SWR_OPERATION_CONTEXT') + +export interface OperationContext { + [OPERATION_CONTEXT_SYMBOL]?: { + key: unknown + type: OperationType + } +} + +export type KeyOptions + = undefined extends TInput ? { input?: TInput } : { input: TInput } + +export type MatcherStrategy = 'exact' | 'partial' + +export type MatcherOptions + = ( + 'partial' extends TStrategy + ? { input?: PartialDeep } + : undefined extends TInput + ? { input?: TInput } + : { input: TInput } + ) & { + strategy?: TStrategy + } + +export type Matcher = (key?: unknown) => boolean + +export type FetcherOptions + = object extends TClientContext ? { context?: TClientContext } : { context: TClientContext } + +export type SubscriberOptions = FetcherOptions & { + /** + * Determines how data is handled when the subscription is subscribed again + * + * - `'reset'`: Clears existing data before streaming new data. + * - `'append'`: Adds new streamed chunks to the existing data. + * - `'replace'`: Buffers streamed data and replaces existing data after the stream completes. + * + * @default 'reset' + */ + refetchMode?: 'append' | 'reset' | 'replace' + + /** + * Maximum number of chunks to store. + * When exceeded, the oldest chunks will be removed. + */ + maxChunks?: number +} + +export type MutatorOptions = FetcherOptions + +export type Fetcher = (key: OperationKey) => Promise + +export type Mutator = (key: unknown, options: Readonly<{ arg: TInput }>) => Promise + +export type Subscriber = ( + key: OperationKey, + options: SWRSubscriptionOptions, +) => (() => void) diff --git a/packages/swr/src/utils.test.ts b/packages/swr/src/utils.test.ts new file mode 100644 index 000000000..04ee5a89f --- /dev/null +++ b/packages/swr/src/utils.test.ts @@ -0,0 +1,43 @@ +import { isSubsetOf } from './utils' + +describe('isSubsetOf', () => { + it('returns true for identical values and valid subsets', () => { + // Identical values + expect(isSubsetOf([1, 2], [1, 2])).toBe(true) + expect(isSubsetOf({}, {})).toBe(true) + expect(isSubsetOf([], [])).toBe(true) + + // Object subsets + expect(isSubsetOf({ a: 1 }, { a: 1, b: 2 })).toBe(true) + expect(isSubsetOf({}, { a: 1 })).toBe(true) + expect(isSubsetOf({ a: undefined }, { a: 1 })).toBe(true) + + // Array subsets + expect(isSubsetOf([1, 2], [1, 2, 3])).toBe(true) + expect(isSubsetOf([], [1])).toBe(true) + + // Nested structures + expect(isSubsetOf({ a: { b: 2 } }, { a: { b: 2, c: 3 } })).toBe(true) + expect(isSubsetOf({ a: { b: [1, 2] } }, { a: { b: [1, 2, 3] } })).toBe(true) + }) + + it('returns false for type mismatches and non-subsets', () => { + // Type mismatches + expect(isSubsetOf([1, 2], 'string')).toBe(false) + expect(isSubsetOf({ a: 1 }, [1, 2])).toBe(false) + + // Non-subset objects + expect(isSubsetOf({ a: 1 }, { a: 2 })).toBe(false) + expect(isSubsetOf({ a: { b: 2 } }, { a: { b: 3 } })).toBe(false) + expect(isSubsetOf({ a: { b: 2 } }, { a: undefined })).toBe(false) + + // Non-subset arrays + expect(isSubsetOf([1, 2], [2, 3])).toBe(false) + expect(isSubsetOf([[1]], [[2]])).toBe(false) + + // Different instances + const date1 = new Date() + const date2 = new Date(date1) + expect(isSubsetOf(date1, date2)).toBe(false) + }) +}) diff --git a/packages/swr/src/utils.ts b/packages/swr/src/utils.ts new file mode 100644 index 000000000..4800dcb2e --- /dev/null +++ b/packages/swr/src/utils.ts @@ -0,0 +1,13 @@ +import { isPlainObject } from '@orpc/shared' + +export function isSubsetOf(subsetKey: unknown, fullKey: unknown): boolean { + return subsetKey === fullKey + ? true + : typeof subsetKey !== typeof fullKey + ? false + : isPlainObject(subsetKey) && isPlainObject(fullKey) + ? Object.keys(subsetKey).every(key => subsetKey[key] === undefined || isSubsetOf(subsetKey[key], fullKey[key])) + : Array.isArray(subsetKey) && Array.isArray(fullKey) + ? subsetKey.every((value, index) => isSubsetOf(value, fullKey[index])) + : false +} diff --git a/packages/swr/tests/__shared__/orpc.ts b/packages/swr/tests/__shared__/orpc.ts new file mode 100644 index 000000000..867780fbd --- /dev/null +++ b/packages/swr/tests/__shared__/orpc.ts @@ -0,0 +1,44 @@ +import type { RouterClient } from '@orpc/server' +import { createORPCClient } from '@orpc/client' +import { RPCLink } from '@orpc/client/fetch' +import { asyncIteratorObject, os } from '@orpc/server' +import { RPCHandler } from '@orpc/server/fetch' +import z from 'zod' +import { createSWRUtils } from '../../src' + +const staticProcedure = os + .errors({ STATIC_ERROR: { data: z.object({ static: z.string() }) } }) + .input(z.object({ input: z.number() })) + .output(z.object({ output: z.string() })) + .handler(vi.fn(({ input }) => ({ output: input.input.toString() }))) + +export const streamHandler = vi.fn(async function* ({ input }: { input?: { input: number } }) { + for (let i = 0; i < (input?.input ?? 0); i++) { + yield { output: i.toString() } + } +}) + +export const router = { + static: staticProcedure, + stream: os + .errors({ STREAM_ERROR: { data: z.object({ stream: z.string() }) } }) + .input(z.object({ input: z.number() }).optional()) + .output(asyncIteratorObject(z.object({ output: z.string() }))) + .handler(streamHandler), + nested: { + static: staticProcedure, + }, +} + +const handler = new RPCHandler(router) + +// prefer createORPCClient over createRouterClient for more close realistic +export const client: RouterClient = createORPCClient(new RPCLink({ + origin: 'http://localhost', + fetch: async (url, init) => { + const { response } = await handler.handle(new Request(url, init)) + return response ?? new Response('Not Found', { status: 404 }) + }, +})) + +export const orpc = createSWRUtils(client) diff --git a/packages/swr/tests/e2e.test-d.ts b/packages/swr/tests/e2e.test-d.ts new file mode 100644 index 000000000..40bfa6160 --- /dev/null +++ b/packages/swr/tests/e2e.test-d.ts @@ -0,0 +1,82 @@ +import { isInferableError } from '@orpc/client' +import useSWR from 'swr' +import useSWRInfinite from 'swr/infinite' +import useSWRMutation from 'swr/mutation' +import useSWRSubscription from 'swr/subscription' +import { client, orpc } from './__shared__/orpc' + +it('.call', () => { + expectTypeOf(orpc.static.call).toEqualTypeOf(client.static) +}) + +it('useSWR', () => { + const swr = useSWR( + orpc.nested.static.key({ input: { input: 123 } }), + orpc.static.fetcher(), + ) + + expectTypeOf(swr.data).toEqualTypeOf<{ output: string } | undefined>() + + // FIXME: this should be an error because invalid key + useSWR( + 'invalid', + orpc.static.fetcher(), + ) +}) + +it('useSWRMutation', () => { + const mutation = useSWRMutation( + orpc.nested.static.key({ input: { input: 123 } }), + orpc.static.mutator(), + ) + + expectTypeOf[0]>().toEqualTypeOf<{ input: number }>() + expectTypeOf(mutation.data).toEqualTypeOf<{ output: string } | undefined>() +}) + +it('useSWRInfinite', () => { + const swr = useSWRInfinite( + index => orpc.nested.static.key({ input: { input: index + 1 } }), + orpc.static.fetcher(), + ) + + expectTypeOf(swr.data).toEqualTypeOf | undefined>() +}) + +it('useSWRSubscription with subscriber', () => { + const subscription = useSWRSubscription( + orpc.stream.key({ input: { input: 3 } }), + orpc.stream.subscriber({ maxChunks: 2 }), + ) + + expectTypeOf(subscription.data).toEqualTypeOf | undefined>() + + if (subscription.error && isInferableError(subscription.error) && subscription.error.code === 'STREAM_ERROR') { + expectTypeOf(subscription.error.data).toEqualTypeOf<{ stream: string }>() + } + + useSWRSubscription( + 'invalid', + // @ts-expect-error: invalid key + orpc.stream.subscriber({ maxChunks: 2 }), + ) +}) + +it('useSWRSubscription with liveSubscriber', () => { + const subscription = useSWRSubscription( + orpc.stream.key({ input: { input: 3 } }), + orpc.stream.liveSubscriber(), + ) + + expectTypeOf(subscription.data).toEqualTypeOf<{ output: string } | undefined>() + + if (subscription.error && isInferableError(subscription.error) && subscription.error.code === 'STREAM_ERROR') { + expectTypeOf(subscription.error.data).toEqualTypeOf<{ stream: string }>() + } + + useSWRSubscription( + 'invalid', + // @ts-expect-error: invalid key + orpc.stream.liveSubscriber(), + ) +}) diff --git a/packages/swr/tests/e2e.test.tsx b/packages/swr/tests/e2e.test.tsx new file mode 100644 index 000000000..f318788e0 --- /dev/null +++ b/packages/swr/tests/e2e.test.tsx @@ -0,0 +1,148 @@ +import { act, renderHook } from '@testing-library/react' +import useSWR, { mutate } from 'swr' +import useSWRInfinite from 'swr/infinite' +import useSWRMutation from 'swr/mutation' +import useSWRSubscription from 'swr/subscription' +import { orpc, streamHandler } from './__shared__/orpc' + +beforeEach(async () => { + vi.clearAllMocks() + // Clear SWR's global cache to avoid carrying data between tests + await mutate(() => true, undefined, { revalidate: false }) +}) + +it('case: useSWR & mutate & useSWRMutation', async () => { + const fetcher = vi.fn(orpc.static.fetcher()) + + const { result } = renderHook(() => { + const swr = useSWR( + orpc.nested.static.key({ input: { input: 123 } }), + fetcher, + ) + const mutation = useSWRMutation( + orpc.nested.static.key({ input: { input: 123 } }), + orpc.static.mutator(), + ) + + return { swr, mutation } + }) + + expect(result.current.swr.isLoading).toBe(true) + + await act(async () => { + await vi.waitFor(() => expect(result.current.swr.data).toEqual({ output: '123' })) + }) + expect(fetcher).toHaveBeenCalledTimes(1) + + await act(async () => { + await result.current.mutation.trigger({ input: 456 }) + }) + expect(fetcher).toHaveBeenCalledTimes(2) + + await act(async () => { + await mutate(orpc.matcher()) + }) + expect(fetcher).toHaveBeenCalledTimes(3) + + await act(async () => { + await mutate(orpc.nested.static.matcher({ input: { input: 123 }, strategy: 'exact' })) + }) + expect(fetcher).toHaveBeenCalledTimes(4) + + await act(async () => { + await mutate(orpc.stream.matcher()) + }) + // not matched - no invalidate happens + expect(fetcher).toHaveBeenCalledTimes(4) +}) + +it('case: useSWRInfinite', async () => { + const { result } = renderHook(() => { + const swr = useSWRInfinite( + index => orpc.nested.static.key({ input: { input: index + 1 } }), + orpc.static.fetcher(), + ) + + return { swr } + }) + + expect(result.current.swr.isLoading).toBe(true) + + await act(async () => { + await vi.waitFor(() => expect(result.current.swr.data).toEqual([{ output: '1' }])) + result.current.swr.setSize(2) + }) + + await act(async () => { + await vi.waitFor(() => expect(result.current.swr.data).toEqual([{ output: '1' }, { output: '2' }])) + result.current.swr.setSize(3) + }) + + await act(async () => { + await vi.waitFor(() => expect(result.current.swr.data).toEqual([{ output: '1' }, { output: '2' }, { output: '3' }])) + }) +}) + +it('case: useSWRSubscription & .subscriber', async () => { + const { result } = renderHook(() => { + const subscription = useSWRSubscription( + orpc.stream.key({ input: { input: 3 } }), + orpc.stream.subscriber({ maxChunks: 2 }), + ) + + return { subscription } + }) + + expect(result.current.subscription.data).toBeUndefined() + + await act(async () => { + await vi.waitFor(() => expect(result.current.subscription.data).toEqual([{ output: '1' }, { output: '2' }])) + }) +}) + +it('case: useSWRSubscription & .subscriber refetchMode=replace', async () => { + const first = renderHook(() => useSWRSubscription( + orpc.stream.key({ input: { input: 3 } }), + orpc.stream.subscriber({ refetchMode: 'replace' }), + )) + + await act(async () => { + await vi.waitFor(() => expect(first.result.current.data).toEqual([{ output: '0' }, { output: '1' }, { output: '2' }])) + }) + + first.unmount() + + streamHandler.mockImplementationOnce(async function* () { + yield { output: '__replaced__' } + }) + + const second = renderHook(() => useSWRSubscription( + orpc.stream.key({ input: { input: 3 } }), + orpc.stream.subscriber({ refetchMode: 'replace' }), + )) + + // previous data is preserved while the new stream is buffering + expect(second.result.current.data).toEqual([{ output: '0' }, { output: '1' }, { output: '2' }]) + + await act(async () => { + // append mode would end with 4 events here, replace keeps only the new stream's events + await vi.waitFor(() => expect(second.result.current.data).toEqual([{ output: '__replaced__' }])) + }) +}) + +it('case: useSWRSubscription & .liveSubscriber', async () => { + const { result } = renderHook(() => { + const subscription = useSWRSubscription( + orpc.stream.key({ input: { input: 4 } }), + orpc.stream.liveSubscriber(), + ) + + return { subscription } + }) + + expect(result.current.subscription.data).toBeUndefined() + + await act(async () => { + await vi.waitFor(() => expect(result.current.subscription.data).toEqual({ output: '3' })) + }) +}) diff --git a/packages/swr/tsconfig.json b/packages/swr/tsconfig.json new file mode 100644 index 000000000..b6ee325dd --- /dev/null +++ b/packages/swr/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.lib.json", + "references": [ + { "path": "../client" }, + { "path": "../shared" } + ], + "include": ["package.json", "src"], + "exclude": [ + "**/*.test.*", + "**/*.test-d.ts", + "**/*.bench.*", + "**/__tests__/**", + "**/__mocks__/**", + "**/__snapshots__/**" + ] +} diff --git a/packages/tanstack-query/README.md b/packages/tanstack-query/README.md index 2dc339da7..c9e3e749a 100644 --- a/packages/tanstack-query/README.md +++ b/packages/tanstack-query/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/trpc/README.md b/packages/trpc/README.md index df2f7ba21..dec87d26e 100644 --- a/packages/trpc/README.md +++ b/packages/trpc/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/valibot/README.md b/packages/valibot/README.md index 9061eedb1..4eb35a969 100644 --- a/packages/valibot/README.md +++ b/packages/valibot/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/packages/zod/README.md b/packages/zod/README.md index b72653ec8..d7fa3282c 100644 --- a/packages/zod/README.md +++ b/packages/zod/README.md @@ -52,6 +52,7 @@ You can read the documentation [here](https://orpc.dev). - [@orpc/ai-sdk](https://www.npmjs.com/package/@orpc/ai-sdk): Turn contracts and procedures into [AI SDK](https://ai-sdk.dev/) tools. - [@orpc/tanstack-query](https://www.npmjs.com/package/@orpc/tanstack-query): Integrate with [TanStack Query](https://tanstack.com/query/latest). - [@orpc/pinia-colada](https://www.npmjs.com/package/@orpc/pinia-colada): Integrate with [Pinia Colada](https://pinia-colada.esm.dev/). +- [@orpc/swr](https://www.npmjs.com/package/@orpc/swr): Integrate with [SWR](https://swr.vercel.app/). - [@orpc/experimental-effect](https://www.npmjs.com/package/@orpc/experimental-effect): Integrate with [Effect](https://effect.website/). - [@orpc/nest](https://www.npmjs.com/package/@orpc/nest): Implement your contract with [NestJS](https://nestjs.com/). - [@orpc/bun](https://www.npmjs.com/package/@orpc/bun): Adapters for [Bun's Redis](https://bun.sh/). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f14ee9a6..21d919253 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -790,6 +790,22 @@ importers: specifier: ^4.4.3 version: 4.4.3 + packages/swr: + dependencies: + '@orpc/client': + specifier: workspace:* + version: link:../client + '@orpc/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + swr: + specifier: ^2.4.2 + version: 2.4.2(react@19.2.7) + zod: + specifier: ^4.4.3 + version: 4.4.3 + packages/tanstack-query: dependencies: '@orpc/client': @@ -9399,6 +9415,11 @@ packages: swagger-ui@5.32.9: resolution: {integrity: sha512-F+RBm5bswwJ3oxlYkcyH5EkM6hHZsJEiSPi8zWw66ncBpuue3sz9dmiXepWYv8BiHqnK82TAHyjoyYLHkEVm+w==} + swr@2.4.2: + resolution: {integrity: sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + swrv@1.2.0: resolution: {integrity: sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==} peerDependencies: @@ -19678,6 +19699,12 @@ snapshots: - debug - supports-color + swr@2.4.2(react@19.2.7): + dependencies: + dequal: 2.0.3 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + swrv@1.2.0(vue@3.5.40(typescript@6.0.3)): dependencies: vue: 3.5.40(typescript@6.0.3) diff --git a/vitest.config.ts b/vitest.config.ts index 8f4cc3351..fb0e5d228 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -48,6 +48,7 @@ export default defineConfig(({ mode }) => ({ './packages/next/**/*.test.tsx', './packages/tanstack-query/**/*.test.tsx', './packages/pinia-colada/**/*.test.tsx', + './packages/swr/**/*.test.tsx', ], benchmark: { exclude: ['**/**'],