diff --git a/client-vue-query.ts b/client-vue-query.ts new file mode 100644 index 0000000..d673e70 --- /dev/null +++ b/client-vue-query.ts @@ -0,0 +1 @@ +export * from './dist/client-vue-query/index' \ No newline at end of file diff --git a/docs/content/1.get-started/2.usage/3.simple-vue-query.md b/docs/content/1.get-started/2.usage/3.simple-vue-query.md new file mode 100644 index 0000000..d26b138 --- /dev/null +++ b/docs/content/1.get-started/2.usage/3.simple-vue-query.md @@ -0,0 +1,177 @@ +--- +title: Simple Vue Query +description: tRPC-Nuxt provides first class integration with tRPC. +--- + +# Simple Usage + +## 1. Create a tRPC router + +Initialize your tRPC backend using the `initTRPC` function and create your first router. + +::code-group + +```ts [server/trpc/trpc.ts] +/** + * This is your entry point to setup the root configuration for tRPC on the server. + * - `initTRPC` should only be used once per app. + * - We export only the functionality that we use so we can enforce which base procedures should be used + * + * Learn how to create protected base procedures and other things below: + * @see https://trpc.io/docs/v10/router + * @see https://trpc.io/docs/v10/procedures + */ +import { initTRPC } from '@trpc/server' + +const t = initTRPC.create() + +/** + * Unprotected procedure + **/ +export const publicProcedure = t.procedure; + +export const router = t.router; +export const middleware = t.middleware; +``` + +```ts [server/api/trpc/[trpc].ts] +/** + * This is the API-handler of your app that contains all your API routes. + * On a bigger app, you will probably want to split this file up into multiple files. + */ +import { createNuxtApiHandler } from 'trpc-nuxt' +import { publicProcedure, router } from '~/server/trpc/trpc' +import { z } from 'zod' + +export const appRouter = router({ + hello: publicProcedure + // This is the input schema of your procedure + .input( + z.object({ + text: z.string().nullish(), + }), + ) + .query(({ input }) => { + // This is what you're returning to your client + return { + greeting: `hello ${input?.text ?? 'world'}`, + } + }), + login: publicProcedure + // using zod schema to validate and infer input values + .input( + z.object({ + name: z.string(), + }) + ) + .mutation(({ input }) => { + // Here some login stuff would happen + return { + user: { + name: input.name, + role: "ADMIN", + }, + }; + }), +}) + +// export only the type definition of the API +// None of the actual implementation is exposed to the client +export type AppRouter = typeof appRouter + +// export API handler +export default createNuxtApiHandler({ + router: appRouter, + createContext: () => ({}), +}) +``` + +:: + +## 2. Create Vue Query and tRPC client plugin + +Create a strongly-typed plugin using your API's type signature. + +::code-group + +```ts [plugins/1.vue-query.ts] +import type { DehydratedState, VueQueryPluginOptions } from "@tanstack/vue-query" +import { VueQueryPlugin, QueryClient, hydrate, dehydrate } from "@tanstack/vue-query" +// Nuxt 3 app aliases +import { useState } from "#app" + +export default defineNuxtPlugin((nuxt) => { + const vueQueryState = useState("vue-query") + + // Modify your Vue Query global settings here + const queryClient = new QueryClient({ + defaultOptions: { queries: { staleTime: 5000 } }, + }); + const options: VueQueryPluginOptions = { queryClient } + + nuxt.vueApp.use(VueQueryPlugin, options) + + if (process.server) { + nuxt.hooks.hook("app:rendered", () => { + vueQueryState.value = dehydrate(queryClient) + }); + } + + if (process.client) { + nuxt.hooks.hook("app:created", () => { + hydrate(queryClient, vueQueryState.value) + }); + } +}) + +``` + +```ts [plugins/2.client.ts] +import { createTRPCNuxtClient, httpBatchLink } from "trpc-nuxt/client-vue-query" +import type { AppRouter } from '~/server/api/trpc/[trpc]' + +export default defineNuxtPlugin(() => { + /** + * createTRPCNuxtClient adds a `useQuery` composable + * built on top of `useAsyncData`. + */ + const client = createTRPCNuxtClient({ + links: [ + httpBatchLink({ + url: '/api/trpc', + }), + ], + }) + + return { + provide: { + client, + }, + } +}) +``` + +:: + +## 3. Make an API request + +```vue [pages/index.vue] + + + +``` diff --git a/package.json b/package.json index 2d03a3e..9250c69 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,11 @@ "types": "./dist/client/index.d.ts", "require": "./dist/client/index.cjs", "import": "./dist/client/index.mjs" + }, + "./client-vue-query": { + "types": "./dist/client-vue-query/index.d.ts", + "require": "./dist/client-vue-query/index.cjs", + "import": "./dist/client-vue-query/index.mjs" } }, "main": "./dist/index.mjs", @@ -22,7 +27,8 @@ "types": "./dist/index.d.ts", "files": [ "dist", - "client.d.ts" + "client.d.ts", + "client-vue-query.d.ts" ], "scripts": { "dev": "concurrently \"pnpm build --watch\" \"pnpm --filter playground dev\"", @@ -35,6 +41,7 @@ "update-deps": "taze -w && pnpm i" }, "peerDependencies": { + "@tanstack/vue-query": "^4.22.0", "@trpc/client": "^10.8.0", "@trpc/server": "^10.8.0" }, @@ -46,6 +53,7 @@ }, "devDependencies": { "@nuxt/eslint-config": "^0.1.1", + "@tanstack/vue-query": "^4.22.0", "@trpc/client": "^10.8.1", "@trpc/server": "^10.8.1", "bumpp": "^8.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c2ce17..1044c24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,7 @@ importers: .: specifiers: '@nuxt/eslint-config': ^0.1.1 + '@tanstack/vue-query': ^4.22.0 '@trpc/client': ^10.8.1 '@trpc/server': ^10.8.1 bumpp: ^8.2.1 @@ -27,6 +28,7 @@ importers: ufo: 1.0.1 devDependencies: '@nuxt/eslint-config': 0.1.1_eslint@8.30.0 + '@tanstack/vue-query': 4.22.0 '@trpc/client': 10.8.1_@trpc+server@10.8.1 '@trpc/server': 10.8.1 bumpp: 8.2.1 @@ -1299,6 +1301,32 @@ packages: resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} dev: true + /@tanstack/match-sorter-utils/8.7.6: + resolution: {integrity: sha512-2AMpRiA6QivHOUiBpQAVxjiHAA68Ei23ZUMNaRJrN6omWiSFLoYrxGcT6BXtuzp0Jw4h6HZCmGGIM/gbwebO2A==} + engines: {node: '>=12'} + dependencies: + remove-accents: 0.4.2 + dev: true + + /@tanstack/query-core/4.22.0: + resolution: {integrity: sha512-OeLyBKBQoT265f5G9biReijeP8mBxNFwY7ZUu1dKL+YzqpG5q5z7J/N1eT8aWyKuhyDTiUHuKm5l+oIVzbtrjw==} + dev: true + + /@tanstack/vue-query/4.22.0: + resolution: {integrity: sha512-G+b1sumfG/YHRvFqVGUJFP+QZVlImsazXhjy/xZ0rD1rCI9fSM8FMaACSHH323EKnkcor7bBjzQqCKyflyk3Ug==} + peerDependencies: + '@vue/composition-api': ^1.1.2 + vue: ^2.5.0 || ^3.0.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + dependencies: + '@tanstack/match-sorter-utils': 8.7.6 + '@tanstack/query-core': 4.22.0 + '@vue/devtools-api': 6.4.5 + vue-demi: 0.13.11 + dev: true + /@tootallnate/once/2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -6258,6 +6286,10 @@ packages: unified: 10.1.2 dev: true + /remove-accents/0.4.2: + resolution: {integrity: sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA==} + dev: true + /require-directory/2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} diff --git a/src/client/links.ts b/src/client-shared/links.ts similarity index 100% rename from src/client/links.ts rename to src/client-shared/links.ts diff --git a/src/client-vue-query/index.ts b/src/client-vue-query/index.ts new file mode 100644 index 0000000..f78c86f --- /dev/null +++ b/src/client-vue-query/index.ts @@ -0,0 +1,81 @@ +import { useQuery, useMutation } from "@tanstack/vue-query" +import { + type CreateTRPCClientOptions, + type inferRouterProxyClient, + createTRPCProxyClient, +} from "@trpc/client" +import { type AnyRouter } from "@trpc/server" +import { createFlatProxy, createRecursiveProxy } from "@trpc/server/shared" +import { type DecoratedProcedureRecord } from "./types" +// @ts-expect-error: Nuxt auto-imports +import { getCurrentInstance, onScopeDispose, useAsyncData } from "#imports" +import { getQueryKey } from "./internals/getQueryKey" +import { getArrayQueryKey } from "./internals/getArrayQueryKey" + +export function getClientArgs( + pathAndInput: TPathAndInput, + opts: TOptions +) { + const [path, input] = pathAndInput + return [path, input, (opts as any)?.trpc] as const +} + +export function createNuxtProxyDecoration( + name: string, + client: inferRouterProxyClient +) { + return createRecursiveProxy((opts) => { + const args = opts.args + + const pathCopy = [name, ...opts.path] + + // The last arg is for instance `.useMutation` or `.useQuery()` + const lastArg = pathCopy.pop()! + + // The `path` ends up being something like `post.byId` + const path = pathCopy.join(".") + + if (lastArg === "useMutation") { + const actualPath = Array.isArray(path) ? path[0] : path + return useMutation({ + ...args, + mutationKey: [actualPath.split(".")], + mutationFn: (input: any) => (client as any)[actualPath].mutate(input), + }) + } + + const [input, ...rest] = args + + const queryKey = getQueryKey(path, input) + + // Expose queryKey helper + if (lastArg === "getQueryKey") { + return getArrayQueryKey(queryKey, (rest[0] as any) ?? "any") + } + + if (lastArg === "useQuery") { + const { trpc, ...options } = rest[0] || ({} as any) + return useQuery({ + ...options, + queryKey, + queryFn: () => (client as any)[path].query(input, trpc), + }) + } + + return (client as any)[path][lastArg](input) + }) +} + +export function createTRPCNuxtClient( + opts: CreateTRPCClientOptions +) { + const client = createTRPCProxyClient(opts) + + const decoratedClient = createFlatProxy((key) => { + return createNuxtProxyDecoration(key, client) + }) as DecoratedProcedureRecord + + return decoratedClient +} + +export { httpBatchLink, httpLink } from "../client-shared/links" diff --git a/src/client-vue-query/internals/getArrayQueryKey.ts b/src/client-vue-query/internals/getArrayQueryKey.ts new file mode 100644 index 0000000..aa2cbfc --- /dev/null +++ b/src/client-vue-query/internals/getArrayQueryKey.ts @@ -0,0 +1,39 @@ +export type QueryType = 'query' | 'infinite' | 'any' + +export type QueryKey = [ + string[], + { input?: unknown; type?: Exclude }?, +] + +/** + * To allow easy interactions with groups of related queries, such as + * invalidating all queries of a router, we use an array as the path when + * storing in tanstack query. This function converts from the `.` separated + * path passed around internally by both the legacy and proxy implementation. + * https://github.com/trpc/trpc/issues/2611 + **/ +export function getArrayQueryKey( + queryKey: string | [string] | [string, ...unknown[]] | unknown[], + type: QueryType, +): QueryKey { + const queryKeyArrayed = Array.isArray(queryKey) ? queryKey : [queryKey] + const [path, input] = queryKeyArrayed + + const arrayPath = + typeof path !== 'string' || path === '' ? [] : path.split('.') + + // Construct a query key that is easy to destructure and flexible for + // partial selecting etc. + // https://github.com/trpc/trpc/issues/3128 + if (!input && (!type || type === 'any')) + // for `utils.invalidate()` to match all queries (including vanilla react-query) + // we don't want nested array if path is empty, i.e. `[]` instead of `[[]]` + return arrayPath.length ? [arrayPath] : ([] as unknown as QueryKey) + return [ + arrayPath, + { + ...(typeof input !== 'undefined' && { input: input }), + ...(type && type !== 'any' && { type: type }), + }, + ] +} diff --git a/src/client-vue-query/internals/getQueryKey.ts b/src/client-vue-query/internals/getQueryKey.ts new file mode 100644 index 0000000..45e9303 --- /dev/null +++ b/src/client-vue-query/internals/getQueryKey.ts @@ -0,0 +1,11 @@ +/** + * We treat `undefined` as an input the same as omitting an `input` + * https://github.com/trpc/trpc/issues/2290 + */ +export function getQueryKey( + path: string, + input: unknown, +): [string] | [string, unknown] { + if (path.length) return input === undefined ? [path] : [path, input] + return [] as unknown as [string] +} diff --git a/src/client-vue-query/types.ts b/src/client-vue-query/types.ts new file mode 100644 index 0000000..24edabb --- /dev/null +++ b/src/client-vue-query/types.ts @@ -0,0 +1,214 @@ +import type { + TRPCClientErrorLike, + TRPCRequestOptions as _TRPCRequestOptions, +} from "@trpc/client"; +import { type TRPCSubscriptionObserver } from "@trpc/client/dist/internals/TRPCUntypedClient"; +import type { + AnyMutationProcedure, + AnyProcedure, + AnyQueryProcedure, + AnyRouter, + ProcedureRouterRecord, + inferProcedureInput, + inferProcedureOutput, + ProcedureArgs, + AnySubscriptionProcedure, +} from "@trpc/server"; +import { + type inferObservableValue, + type Unsubscribable, +} from "@trpc/server/observable"; +import { inferTransformedProcedureOutput } from "@trpc/server/shared"; + +import type { + // DefinedUseQueryResult, + // DehydratedState, + // InfiniteQueryObserverSuccessResult, + InitialDataFunction, + // QueryObserverSuccessResult, + // QueryOptions, + // UseInfiniteQueryOptions, + // UseInfiniteQueryResult, + UseMutationOptions, + UseMutationReturnType, + UseQueryDefinedReturnType, + UseQueryOptions, + UseQueryReturnType, + // UseQueryResult, +} from "@tanstack/vue-query"; + +interface TRPCRequestOptions extends _TRPCRequestOptions { + abortOnUnmount?: boolean; +} + +type Resolver = ( + ...args: ProcedureArgs +) => Promise>; + +type SubscriptionResolver< + TProcedure extends AnyProcedure, + TRouter extends AnyRouter +> = ( + ...args: [ + input: ProcedureArgs[0], + opts: ProcedureArgs[1] & + Partial< + TRPCSubscriptionObserver< + inferObservableValue>, + TRPCClientErrorLike + > + > + ] +) => Unsubscribable; + +export type DecorateProcedure< + TProcedure extends AnyProcedure, + TRouter extends AnyRouter, + TPath extends string, +> = TProcedure extends AnyQueryProcedure + ? { + useQuery: ProcedureUseQuery; + query: Resolver; + } + : TProcedure extends AnyMutationProcedure + ? { + useMutation: ( + opts?: UseTRPCMutationOptions< + inferProcedureInput, + TRPCClientErrorLike, + inferTransformedProcedureOutput, + TContext + >, + ) => UseTRPCMutationResult< + inferTransformedProcedureOutput, + TRPCClientErrorLike, + inferProcedureInput, + TContext + >; + mutate: Resolver; + } + : TProcedure extends AnySubscriptionProcedure + ? { + subscribe: SubscriptionResolver; + } + : never; + +/** + * @internal + */ +export type DecoratedProcedureRecord< + TProcedures extends ProcedureRouterRecord, + TRouter extends AnyRouter, + TPath extends string = '' +> = { + [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter + ? DecoratedProcedureRecord + : TProcedures[TKey] extends AnyProcedure + ? DecorateProcedure + : never; +}; + +export interface ProcedureUseQuery< + TProcedure extends AnyProcedure, + TPath extends string +> { + < + TQueryFnData = inferTransformedProcedureOutput, + TData = inferTransformedProcedureOutput + >( + input: inferProcedureInput, + opts: DefinedUseTRPCQueryOptions< + TPath, + inferProcedureInput, + TQueryFnData, + TData, + TRPCClientErrorLike + > + ): DefinedUseTRPCQueryResult>; + + < + TQueryFnData = inferTransformedProcedureOutput, + TData = inferTransformedProcedureOutput + >( + input: inferProcedureInput, + opts?: UseTRPCQueryOptions< + TPath, + inferProcedureInput, + TQueryFnData, + TData, + TRPCClientErrorLike + > + ): UseTRPCQueryResult>; +} + +export interface DefinedUseTRPCQueryOptions< + TPath, + TInput, + TOutput, + TData, + TError +> extends UseTRPCQueryOptions { + initialData: TOutput | InitialDataFunction; +} + +export type DefinedUseTRPCQueryResult = UseQueryDefinedReturnType< + TData, + TError +> & + TRPCHookResult; + +export interface UseTRPCQueryOptions + extends UseQueryOptions, + TRPCUseQueryBaseOptions {} + +export type UseTRPCQueryResult = UseQueryReturnType & + TRPCHookResult; + +export interface DefinedUseTRPCQueryOptions< + TPath, + TInput, + TOutput, + TData, + TError +> extends UseTRPCQueryOptions { + initialData: TOutput | InitialDataFunction; +} + +export interface TRPCHookResult { + trpc: { + path: string; + }; +} + +export interface TRPCUseQueryBaseOptions { + /** + * tRPC-related options + */ + trpc?: TRPCReactRequestOptions; +} + +export interface TRPCReactRequestOptions + // For RQ, we use their internal AbortSignals instead of letting the user pass their own + extends Omit { + /** + * Opt out of SSR for this query by passing `ssr: false` + */ + ssr?: boolean; + /** + * Opt out or into aborting request on unmount + */ + abortOnUnmount?: boolean; +} + + +export interface UseTRPCMutationOptions< + TInput, + TError, + TOutput, + TContext = unknown, +> extends UseMutationOptions, + TRPCUseQueryBaseOptions {} + + export type UseTRPCMutationResult = + UseMutationReturnType & TRPCHookResult; + \ No newline at end of file diff --git a/src/client/index.ts b/src/client/index.ts index b2f0b84..6e5eeb2 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -78,4 +78,4 @@ export function createTRPCNuxtClient (opts: CreateTRP export { httpBatchLink, httpLink -} from './links' +} from '../client-shared/links' \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index 4c2b48e..6d82cc7 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,11 +1,11 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/index.ts', 'src/client/index.ts'], + entry: { "index": 'src/index.ts', "client/index": 'src/client/index.ts', "client-vue-query/index": 'src/client-vue-query/index.ts' }, format: ['cjs', 'esm'], splitting: false, clean: true, - external: ['#app', '#imports', /@trpc\/client/, /@trpc\/server/], + external: ['#app', '#imports', /@trpc\/client/, /@trpc\/client-vue-query/, /@trpc\/server/], dts: true, outExtension ({ format }) { return {