banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

Super combination! NestJS + tRPC and the excellent partner React Query for CSR, ushering in a new era of development!

Recently, I am rewriting a NestJS project, thinking about integrating tRPC for the management panel frontend. Originally, the management panel had bare API interfaces without any response types. If using tRPC, not only can we avoid writing APIs by hand, but we can also achieve End-to-End Type-safe.

What is tRPC#

RPC stands for Remote Procedure Call, and the t in tRPC emphasizes the implementation in TypeScript, utilizing TypeScript's type system to facilitate communication between the client and server while providing efficient type safety.

tRPC is commonly used in full-stack frameworks, such as Next.js. Moreover, after Next.js promotes the App Router, RPC-like methods will also become quite popular, saving a lot of trouble.

Integrating tRPC with NestJS#

Integrating tRPC with NestJS is very simple. It is similar to the integration with a regular server. Below, fastify is used as the adapter for the NestJS application.

NestJS itself is an abstraction layer, and we can access the instance of the source server, which is fastify here.

According to the tRPC documentation, we complete the integration of tRPC by injecting plugins into the fastify instance.

First, install the necessary libraries for tRPC:

pnpm add @trpc/server zod

Create a new NestJS module named trpc.

nest g mo src/processors/trpc --no-spec
nest g s src/processors/trpc --no-spec

Mark the Module as a global module. This will be used later to define the tRPC router in other business modules.

@Module({
  exports: [tRPCService],
  providers: [tRPCService],
})
@Global()
export class tRPCModule {}

Create a trpc.instance.ts to instantiate tRPC. Here, it is necessary to create the instance externally, which seems not very in line with Angular philosophy, but this instance type needs to be externalized, and there is no way around it.

import {
  inferRouterInputs,
  inferRouterOutputs,
  initTRPC,
  inferAsyncReturnType,
} from '@trpc/server'

import { Context } from './trpc.context'

import * as trpcNext from '@trpc/server/adapters/next'

export async function createContext({
  req,
  res,
}: trpcNext.CreateNextContextOptions) {
  return {
    authorization: req.headers.authorization as string | null,
  }
}
export type Context = inferAsyncReturnType<typeof createContext>

export const tRpc = initTRPC.context<Context>().create()

// ====== The following type exports are for Client-side use
export type tRpcRouterType = (typeof tRpc)['router']
export type tRpcProcedure = (typeof tRpc)['procedure']
export type tRpc$Config = typeof tRpc._config

export type RouterInputs = inferRouterInputs<AppRouter>
export type RouterOutputs = inferRouterOutputs<AppRouter>

export type AppRouter = tRPCService['appRouter']

In the above, we created the tRPC instance and injected the authorization header needed for authentication.

Now, let's move to trpc.service.ts.

import { tRpc } from './trpc.instance'
import { fastifyRequestHandler } from '@trpc/server/adapters/fastify'

@Injectable()
export class tRPCService {
  constructor(private readonly authService: AuthService) {
    this._procedureAuth = tRpc.procedure.use(
      // Here we also define the authentication procedure
      tRpc.middleware(async (opts) => {
        const authorization = opts.ctx.authorization
        if (!authorization) {
          throw new BizException(ErrorCodeEnum.AuthFail)
        }

        const result = await authService.validate(authorization)
        if (result !== true) {
          throw new BizException(result)
        }
        return opts.next()
      }),
    )
    this.createAppRouter()
  }

  public get t() {
    return tRpc
  }

  private _procedureAuth: typeof tRpc.procedure

  public get procedureAuth() {
    return this._procedureAuth
  }

  appRouter: ReturnType<typeof this.createAppRouter>

  private createAppRouter() {
    const appRouter = tRpc.router({
      user: tRpc.router({
        list: tRpc.procedure.query(() => []),
      }),
    })

    this.appRouter = appRouter
    return appRouter
  }

  applyMiddleware(app: NestFastifyApplication) {
    app.getHttpAdapter().all(`/trpc/:path`, async (req, res) => {
      const path = (req.params as any).path
      await fastifyRequestHandler({
        router: this.appRouter,
        createContext,
        req,
        res,
        path,
      })
    })
  }
}

In the above, we defined the tRPC router. Now we need to perform some operations on the fastify app.

First, inject the tRPCModule into the AppModule. Now, in the bootstrap, get the fastify instance and apply the applyMiddleware method of tRPCService.

::: warning
Here, I did not use the method mentioned in the documentation due to https://github.com/trpc/trpc/issues/4820.
:::

Now, let's move to bootstrap.ts.

import { FastifyAdapter } from '@nestjs/platform-fastify'

const fastifyApp: FastifyAdapter = new FastifyAdapter({
  trustProxy: true,
})
const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  fastifyApp,
)

const trpcService = app.get(tRPCService)
trpcService.applyMiddleware(app)

Thus, a simple tRPC framework is completed, but our progress has just begun.

Now you can access /trpc/user.list to get the following result.

{
  "result": {
    "data": []
  }
}

Defining tRPC Router by Business Module#

Expectations#

Clearly, writing all routers in one file does not conform to the modular philosophy, so in this section, we will split this up as much as possible to align with NestJS philosophy.

First, we need to clarify what we want to do. Our expectation is to store a file named some-biz.trpc.ts under each business module to define the tRPC routes, similar to the naming of some-biz.controller.ts.

For example, we can define the tRPC routes like this.

// modules/user/user.trpc.ts
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable } from '@nestjs/common'

@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(private readonly trpcService: tRPCService) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const tRpc = this.trpcService.t
    return tRpc.router({
      user: tRpc.router({
        list: tRpc.procedure.query(() => []),
      }),
    })
  }
}

Then import it in the Module.

// modules/user/user.module.ts
import { Global, Module } from '@nestjs/common'

import { UserController } from './user.controller'
import { UserService } from './user.service'
import { UserTrpcRouter } from './user.trpc'

@Module({
  controllers: [UserController],
  providers: [UserService, UserTrpcRouter],
  exports: [UserService, UserTrpcRouter],
})
export class UserModule {}

Collecting Dependencies#

The above is our expected approach. So how can the tRPCService automatically collect dependencies?

The DiscoveryModule from @nestjs/core provides us with this method.

First, modify trpc.module.ts:

// helper/trpc.module.ts

import { Global, Module } from '@nestjs/common'
import { DiscoveryModule } from '@nestjs/core'

import { tRPCService } from './trpc.service'

@Module({
  exports: [tRPCService],
  providers: [tRPCService],
  imports: [DiscoveryModule], // Inject DiscoveryModule
})
@Global()
export class tRPCModule {}

Then modify createAppRouter in trpc.service.ts to leverage DiscoveryService to collect all dependencies and extract the necessary trpcRouter. So how to extract it? Here we use Reflector to get the original information and then filter based on that.

import { DiscoveryService, Reflector } from '@nestjs/core'

const TRPC_ROUTER = 'trpc_router'

@Injectable()
export class tRPCService {
  constructor(private readonly discovery: DiscoveryService) {
    // ...
  }

  private createAppRouter() {
    const p = this.discovery.getProviders()
    const routers = p
      .filter((provider) => {
        try {
          return this.reflector.get(TRPC_ROUTER, provider.metatype)
        } catch {
          return false
        }
      })
      .map(({ instance }) => instance.router)
      .filter((router) => {
        if (!router) {
          this.logger.warn('missing router.')
        }

        return !!router
      })

    const appRouter = tRpc.mergeRouters(...(routers as any))

    this.appRouter = appRouter
    return appRouter
  }
}

Since it is original information, there must be a place to add decorations. Our expectation is to add the @TRPCRouter decorator on the tRPC Router class.

First, define TRPCRouter:

const TRPC_ROUTER = 'trpc_router'

export const TRPCRouter = (): ClassDecorator => {
  return (target) => {
    Reflect.defineMetadata(TRPC_ROUTER, true, target)
  }
}

You can place this constant value anywhere.

Then add the decoration to the TRPCRouter.

import { TRPCRouter } from '@core/common/decorators/trpc.decorator'
import { defineTrpcRouter } from '@core/processors/trpc/trpc.helper'
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable, OnModuleInit } from '@nestjs/common'

+ @TRPCRouter()
@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(private readonly trpcService: tRPCService) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const t = this.trpcService.procedureAuth
    return defineTrpcRouter('user', {
      user: t.query(() => []),
    })
  }
}

OK, this completes the first step.

Finally, don't forget to add the TRPCModule to the top-level Module.

::: error
Note here, TRPCModule needs to collect dependencies from various modules, so ensure that TRPCModule is loaded after all business modules.

@Module({
  imports: [
    // processors
    CacheModule,
    DatabaseModule,
    HelperModule,
    LoggerModule,
    GatewayModule,

    // BIZ
    AggregateModule,
    AuthModule,
    PostModule,
    UserModule,
    CategoryModule,
    ConfigsModule,
    NoteModule,
    PageModule,

    // TRPCModule should be after all biz modules
    tRPCModule,
  ],
})
export class AppModule {}

:::

If you are observant, you will notice that I used defineTrpcRouter. What is that? This will also be discussed below.

Type Safety#

Now that we have transformed the definition of tRPC routers by business modules and used automatic dependency collection, type safety has been compromised.

In the above, we used mergeRouters as an any type, and we need to define this any type.

Next, it’s just a matter of boilerplate code; there’s no necessity. The code is pasted here.

// trpc.service.ts
interface TA {
  router: any
}

type ExtractRouterType<T extends TA> = T['router']

type MapToRouterType<T extends any[]> = {
  [K in keyof T]: ExtractRouterType<T[K]>
}

type Routers = MapToRouterType<tRpcRouters>

Then create a new trpc.routes.ts.

import { UserTrpcRouter } from '@core/modules/user/user.trpc'

export type tRpcRouters = [UserTrpcRouter]

Here, we still need to import each tRPC Router class, but it is just a type. I wonder if there is a better way to avoid this step.

Modify the createAppRouter method:

- const appRouter = tRpc.mergeRouters(...(routers as any))
+ const appRouter = tRpc.mergeRouters(...(routers as any as Routers))

By the way, the defineTrpcRouter mentioned above is to solve part of the deeply nested situation of tRPC.router, ultimately serving the type, but I won’t elaborate on it here. The code is as follows:

import { tRpc, tRpcRouterType } from './trpc.instance'

type ObjWithKey<T extends string, Value> = { [K in T]: Value }

export const defineTrpcRouter = <
  T extends string,
  P extends Parameters<tRpcRouterType>[0],
>(
  route: T,
  routes: P,
) => {
  const rpcRoute = tRpc.router(routes)
  return tRpc.router({
    [route]: rpcRoute,
  } as any as ObjWithKey<T, typeof rpcRoute>)
}

Then in user.trpc.ts, we have a simple way to define routes.

import { TRPCRouter } from '@core/common/decorators/trpc.decorator'
import { defineTrpcRouter } from '@core/processors/trpc/trpc.helper'
import { tRPCService } from '@core/processors/trpc/trpc.service'
import { Injectable, OnModuleInit } from '@nestjs/common'

@TRPCRouter()
@Injectable()
export class UserTrpcRouter implements OnModuleInit {
  private router: ReturnType<typeof this.createRouter>
  constructor(
    private readonly trpcService: tRPCService,
    private readonly service: UserService,
  ) {}

  onModuleInit() {
    this.router = this.createRouter()
  }

  private createRouter() {
    const t = this.trpcService.procedureAuth
    // Much cleaner
    return defineTrpcRouter('user', {
      user: t.query(() => []),
      getCurrentUser: t.query(() => this.service.getCurrentUser()),
    })
  }
}

Great job. Next, we need to integrate it on the Client side.

Integrating on the Client Side#

NestJS is not a full-stack framework. Although tRPC can provide a type-complete communication interface, it is still not feasible to obtain a type-complete type instance on the Client side.

If your Client-side code happens to be in the same monorepo as NestJS, then we can continue. If not, please first convert it into a monorepo format.

Now our directory structure looks like this.

├── apps
  ├── console # React
  └── core # NestJS

In the React project, install the necessary dependencies.

pnpm add @trpc/client @trpc/server @trpc/react-query @tanstack/react-query

Use tsPath to map the path @core to apps/core, which is the NestJS project.

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@core/*": ["../core/src/*"]
    }
  },
  "include": ["src"]
}

You can also add an alias in vite.config.ts.

import { resolve } from 'path'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
export default defineConfig({
  // alias
  resolve: {
    alias: [
      {
        find: /^@core\/(.*)/,
        replacement: resolve(__dirname, '../core/src/$1'),
      },
    ],
  },
})

Initialize the tRPC client.

import {
  createTRPCReact,
  httpBatchLink,
  inferReactQueryProcedureOptions,
} from '@trpc/react-query'

import {
  AppRouter,
  RouterInputs,
  RouterOutputs,
} from '@core/processors/trpc/trpc.instance' // Import from the NestJS project

import { API_URL } from '~/constants/env'

import { getToken } from './cookie'

export const trpc = createTRPCReact<AppRouter>()

export const tRpcClient = trpc.createClient({
  links: [
    httpBatchLink({
      url: `${API_URL}/trpc`, // Fill in the service address
      async headers() {
        return {
          authorization: getToken()!, // Authorization header
        }
      },
    }),
  ],
})

export type ReactQueryOptions = inferReactQueryProcedureOptions<AppRouter>

export type { RouterInputs, RouterOutputs }

Mount it in the top-level component:

import { QueryClientProvider } from '@tanstack/react-query'
import { Suspense } from 'react'

import { trpc, tRpcClient } from './lib/trpc'
import { queryClient } from './providers/query-core'

export function App() {
  return (
    <trpc.Provider client={tRpcClient} queryClient={queryClient}>
      <QueryClientProvider client={queryClient}>
        <Suspense>{/** Your App */}</Suspense>
      </QueryClientProvider>
    </trpc.Provider>
  )
}

Now you can use tRPC like this.

const { data, isLoading } = trpc.user.user.useQuery({})

Known issue: tRPC does not support generic type safety.

This article was updated by Mix Space to xLog. The original link is https://innei.in/posts/programming/nestjs-with-trpc-and-dependency-injection

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.