diff --git a/playground/pages/index.vue b/playground/pages/index.vue index 9d6c108..4b47983 100644 --- a/playground/pages/index.vue +++ b/playground/pages/index.vue @@ -1,55 +1,66 @@ diff --git a/playground/plugins/trpc-client.ts b/playground/plugins/trpc-client.ts new file mode 100644 index 0000000..9f55432 --- /dev/null +++ b/playground/plugins/trpc-client.ts @@ -0,0 +1,110 @@ +import type { TRPCClientErrorLike, inferRouterProxyClient } from '@trpc/client' +import { createTRPCProxyClient, httpBatchLink } from '@trpc/client' +import type { AnyMutationProcedure, AnyProcedure, AnyQueryProcedure, AnyRouter, ProcedureRecord, ProcedureRouterRecord, inferHandlerInput, inferProcedureInput, inferProcedureOutput, inferRouterInputs } from '@trpc/server' +import { createFlatProxy, createRecursiveProxy } from '@trpc/server/shared' +import type { + AsyncData, AsyncDataOptions, KeyOfRes, PickFrom, _Transform, +} from 'nuxt/dist/app/composables/asyncData' +import { hash } from 'ohash' +import type { AppRouter } from '~~/server/trpc' + +/** + * Calculates the key used for `useAsyncData` call + */ +export function getQueryKey( + path: string, + input: unknown, +): string { + return input === undefined ? path : `${path}-${hash(input || '')}` +} + +function createNuxtProxyDecoration(name: string, client: inferRouterProxyClient) { + return createRecursiveProxy((opts) => { + const args = opts.args + + const pathCopy = [name, ...opts.path] + + // The last arg is for instance `.mutate` or `.query()` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastArg = pathCopy.pop()! + + const path = pathCopy.join('.') + + const [input, asyncDataOptions] = args + + const queryKey = getQueryKey(path, input) + + if (lastArg === 'mutate') { + return useAsyncData(queryKey, () => (client as any)[path][lastArg](input), { + ...asyncDataOptions as Record, + immediate: false, + }) + } + + return useAsyncData(queryKey, () => (client as any)[path][lastArg](input), asyncDataOptions as Record) + }) +} + +/** + * @internal + */ +export type DecorateProcedure< + TProcedure extends AnyProcedure, + TPath extends string, +> = TProcedure extends AnyQueryProcedure + ? { + query: < + TData = inferProcedureOutput, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes, + >( + input: inferProcedureInput, + opts?: AsyncDataOptions, + ) => AsyncData, PickKeys>, TRPCClientErrorLike> + } : TProcedure extends AnyMutationProcedure ? { + mutate: < + TData = inferProcedureOutput, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes, + >( + input: inferProcedureInput, + opts?: AsyncDataOptions, + ) => AsyncData, PickKeys>, TRPCClientErrorLike> + } : never + +/** +* @internal +*/ +export type DecoratedProcedureRecord< + TProcedures extends ProcedureRouterRecord, + TPath extends string = '', +> = { + [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter + ? DecoratedProcedureRecord< + TProcedures[TKey]['_def']['record'], + `${TPath}${TKey & string}.` + > + : TProcedures[TKey] extends AnyProcedure + ? DecorateProcedure + : never; +} + +export default defineNuxtPlugin(() => { + const client = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: 'http://localhost:3000/trpc', + }), + ], + }) + + const newClient = createFlatProxy((key) => { + return createNuxtProxyDecoration(key, client) + }) as DecoratedProcedureRecord + + return { + provide: { + client: newClient, + }, + } +}) diff --git a/playground/server/trpc/index.ts b/playground/server/trpc/index.ts index 90a81bd..74d14e5 100644 --- a/playground/server/trpc/index.ts +++ b/playground/server/trpc/index.ts @@ -19,13 +19,10 @@ const t = initTRPC.context().create() // We explicitly export the methods we use here // This allows us to create reusable & protected base procedures export const middleware = t.middleware -export const router = t.router +const router = t.router export const publicProcedure = t.procedure -export const appRouter = router({ - getTodos: publicProcedure.query(() => { - return $fetch(`${baseURL}/todos`) - }), +const anotherRouter = router({ getTodo: publicProcedure .input(z.number()) .query((req) => { @@ -41,6 +38,19 @@ export const appRouter = router({ }), }) +export const appRouter = router({ + todo: anotherRouter, + getTodos: publicProcedure.query(() => { + return $fetch(`${baseURL}/todos`) + }), + getTodo: publicProcedure + .input(z.number()) + .query((req) => { + console.log('REQ', req) + return $fetch(`${baseURL}/todos/${req.input}`) + }), +}) + export type AppRouter = typeof appRouter export async function createContext(event: H3Event) { diff --git a/src/module.ts b/src/module.ts index 2df4a06..4fee9f9 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,7 +3,7 @@ import { join, resolve } from 'pathe' import { defu } from 'defu' import dedent from 'dedent' -import { addImports, addPlugin, addServerHandler, addTemplate, defineNuxtModule } from '@nuxt/kit' +import { addPlugin, addServerHandler, addTemplate, defineNuxtModule } from '@nuxt/kit' export interface ModuleOptions { baseURL: string @@ -32,19 +32,19 @@ export default defineNuxtModule({ endpoint: options.endpoint, }) - addImports([ - { name: 'useClient', from: join(runtimeDir, 'client') }, - { name: 'useAsyncQuery', from: join(runtimeDir, 'client') }, - { name: 'useClientHeaders', from: join(runtimeDir, 'client') }, - { name: 'getQueryKey', from: join(runtimeDir, 'client') }, - ]) + // addImports([ + // { name: 'useClient', from: join(runtimeDir, 'client') }, + // { name: 'useAsyncQuery', from: join(runtimeDir, 'client') }, + // { name: 'useClientHeaders', from: join(runtimeDir, 'client') }, + // { name: 'getQueryKey', from: join(runtimeDir, 'client') }, + // ]) addServerHandler({ route: `${finalConfig.endpoint}/*`, handler: handlerPath, }) - addPlugin(resolve(runtimeDir, 'plugin')) + // addPlugin(resolve(runtimeDir, 'plugin')) addTemplate({ filename: 'trpc-handler.ts', @@ -56,6 +56,7 @@ export default defineNuxtModule({ export default createTRPCHandler({ ...functions, + router: functions.appRouter, endpoint: '${finalConfig.endpoint}' }) ` diff --git a/src/runtime/api.ts b/src/runtime/api.ts index d500cb1..e07ef7f 100644 --- a/src/runtime/api.ts +++ b/src/runtime/api.ts @@ -11,6 +11,9 @@ import { createURL } from 'ufo' import type { H3Event } from 'h3' import { defineEventHandler, isMethod, readBody } from 'h3' import type { TRPCResponse } from '@trpc/server/rpc' +import type { CreateTRPCClientOptions, inferRouterProxyClient } from '@trpc/client' +import { createTRPCProxyClient } from '@trpc/client' +import { toRaw } from 'vue' type MaybePromise = T | Promise @@ -88,3 +91,26 @@ export function createTRPCHandler({ return body }) } + +export function createTRPCNuxtClient(opts: CreateTRPCClientOptions) { + const client = createTRPCProxyClient(opts) + + // Object.keys(client).forEach((path) => { + // clientWithOther[path] = {} + // Object.keys(client[path]).forEach((action) => { + // clientWithOther[path][action] = (input: inferRouterInputs) => { + // // @ts-expect-error: asd + // return useAsyncData(`${path}-${action}`, () => client[path][action](input)) + // } + // }) + // }) + + const proxiedClient = new Proxy({}, { + get(target, property) { + // @ts-expect-error: Nuxt + return () => useAsyncData(`${target}-${property}`, () => client.getTodos.query()) + }, + }) + + return proxiedClient as inferRouterProxyClient +} diff --git a/src/runtime/client.ts b/src/runtime/client.ts index 4cdd3c5..ae3d165 100644 --- a/src/runtime/client.ts +++ b/src/runtime/client.ts @@ -1,3 +1,15 @@ +import type { CreateTRPCClientOptions, TRPCClientErrorLike, inferRouterProxyClient } from '@trpc/client' +import { createTRPCProxyClient } from '@trpc/client' +import type { + AnyMutationProcedure, + AnyProcedure, + AnyQueryProcedure, + AnyRouter, + ProcedureRouterRecord, + inferProcedureInput, + inferProcedureOutput, +} from '@trpc/server' +import { createFlatProxy, createRecursiveProxy } from '@trpc/server/shared' import type { AsyncData, AsyncDataOptions, @@ -5,80 +17,97 @@ import type { PickFrom, _Transform, } from 'nuxt/dist/app/composables/asyncData' -import type { - ProcedureRecord, - inferHandlerInput, - inferProcedureInput, - inferProcedureOutput, -} from '@trpc/server' -import type { TRPCClient, TRPCClientErrorLike } from '@trpc/client' -import { objectHash } from 'ohash' -import type { Ref } from 'vue' -import { useAsyncData, useNuxtApp, useState } from '#app' -import type { AppRouter } from '~/server/trpc' - -type MaybeRef = T | Ref - -export type inferProcedures< - TObj extends ProcedureRecord, -> = { - [TPath in keyof TObj]: { - input: inferProcedureInput - output: inferProcedureOutput - }; -} - -export type TQueries = AppRouter['_def']['procedures'] -export type TError = TRPCClientErrorLike - -export type TQueryValues = inferProcedures +import { hash } from 'ohash' /** * Calculates the key used for `useAsyncData` call - * @param pathAndInput */ -export function getQueryKey< - TPath extends keyof TQueryValues & string, - >(pathAndInput: [path: TPath, ...args: inferHandlerInput]) { - return `${pathAndInput[0]}-${objectHash(pathAndInput[1] ? JSON.stringify(pathAndInput[1]) : '')}` +export function getQueryKey( + path: string, + input: unknown, +): string { + return input === undefined ? path : `${path}-${hash(input || '')}` } -export async function useAsyncQuery< - TPath extends keyof TQueryValues & string, - TOutput extends TQueryValues[TPath]['output'] = TQueryValues[TPath]['output'], - Transform extends _Transform = _Transform, - PickKeys extends KeyOfRes = KeyOfRes, ->( - pathAndInput: [path: TPath, ...args: inferHandlerInput], - options: AsyncDataOptions = {}, -): Promise, PickKeys>, TError>> { - const { $client } = useNuxtApp() - const key = getQueryKey(pathAndInput) - const serverError = useState(`error-${key}`, () => null) - const { error, data, ...rest } = await useAsyncData( - key, - () => $client.query(...pathAndInput), - options, - ) +function createNuxtProxyDecoration(name: string, client: inferRouterProxyClient) { + return createRecursiveProxy((opts) => { + const args = opts.args - if (error.value && !serverError.value) - serverError.value = error.value as any + const pathCopy = [name, ...opts.path] - if (data.value) - serverError.value = null + // The last arg is for instance `.mutate` or `.query()` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastArg = pathCopy.pop()! - return { - ...rest, - data, - error: serverError, - } as any + const path = pathCopy.join('.') + + const [input, asyncDataOptions] = args + + const queryKey = getQueryKey(path, input) + + if (lastArg === 'mutate') { + // @ts-expect-error: Nuxt internal + return useAsyncData(queryKey, () => (client as any)[path][lastArg](input), { + ...asyncDataOptions as Record, + immediate: false, + }) + } + + // @ts-expect-error: Nuxt internal + return useAsyncData(queryKey, () => (client as any)[path][lastArg](input), asyncDataOptions as Record) + }) } -export function useClient(): TRPCClient { - const { $client } = useNuxtApp() - return $client +/** + * @internal + */ +export type DecorateProcedure< + TProcedure extends AnyProcedure, + TPath extends string, +> = TProcedure extends AnyQueryProcedure + ? { + query: < + TData = inferProcedureOutput, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes, + >( + input: inferProcedureInput, + opts?: AsyncDataOptions, + ) => AsyncData, PickKeys>, TRPCClientErrorLike> + } : TProcedure extends AnyMutationProcedure ? { + mutate: < + TData = inferProcedureOutput, + Transform extends _Transform = _Transform, + PickKeys extends KeyOfRes = KeyOfRes, + >( + input: inferProcedureInput, + opts?: AsyncDataOptions, + ) => AsyncData, PickKeys>, TRPCClientErrorLike> + } : never + +/** +* @internal +*/ +export type DecoratedProcedureRecord< + TProcedures extends ProcedureRouterRecord, + TPath extends string = '', +> = { + [TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter + ? DecoratedProcedureRecord< + TProcedures[TKey]['_def']['record'], + `${TPath}${TKey & string}.` + > + : TProcedures[TKey] extends AnyProcedure + ? DecorateProcedure + : never; } -export function useClientHeaders(initialValue: MaybeRef> = {}): Ref> { - return useState('trpc-nuxt-header', () => initialValue) +export function createTRPCNuxtProxyClient(opts: CreateTRPCClientOptions) { + const client = createTRPCProxyClient(opts) + + const decoratedClient = createFlatProxy((key) => { + return createNuxtProxyDecoration(key, client) + }) as DecoratedProcedureRecord + + return decoratedClient } diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index 045008f..393f003 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,5 +1,5 @@ +import type { inferRouterProxyClient } from '@trpc/client' import { createTRPCProxyClient, httpBatchLink } from '@trpc/client' -import type { TRPCClient } from '@trpc/client'; import { unref } from 'vue' import { FetchError } from 'ohmyfetch' import { useClientHeaders } from './client' @@ -12,6 +12,7 @@ export default defineNuxtPlugin((nuxtApp) => { const otherHeaders = useClientHeaders() const baseURL = process.server ? '' : config.baseURL + const client = createTRPCProxyClient({ links: [ httpBatchLink({ @@ -43,6 +44,6 @@ export default defineNuxtPlugin((nuxtApp) => { declare module '#app' { interface NuxtApp { - $client: TRPCClient + $client: inferRouterProxyClient } } diff --git a/test.js b/test.js new file mode 100644 index 0000000..8ebf707 --- /dev/null +++ b/test.js @@ -0,0 +1,23 @@ +const data = { + hello: { + log() { + return 'hello log'; + }, + }, + hi: { + log() { + return 'hi log' + }, + }, +} + +const blankObject = {}; + +const proxy = new Proxy(blankObject, { + get(target, key) { + if (key in data) return data[key]; // <--- + return target[key]; // default + } +}); + +console.log(proxy.hello.log()); // hello log;