Original link: https://innei.in/posts/programming/nestjs-with-trpc-and-dependency-injection
This rendering is generated by marked and may have typography issues. For the best experience, please go to: https://innei.in/posts/programming/nestjs-with-trpc-and-dependency-injection
Recently I was rewriting a NestJS project, thinking about integrating tRPC for use by the management panel front-end. Originally, the API interfaces in the management panel were written naked, and there were no response types. If you use tRPC, you can achieve End-to-End Type-safe without handwriting API.
What is tRPC
RPC refers to remote procedure call (Remote procedure call). t
of tRPC emphasizes the implementation of TypeScript, using the TypeScript type system to realize the communication process between the client and the server and provide efficient type safety.
tRPC is generally used in full-stack frameworks, such as Next.js. Moreover, after Next.js promotes App Router, RPC-like methods will become more popular, which can save a lot of trouble.
NestJS connects to tRPC
The way NestJS connects to tRPC is very simple. It is similar to ordinary server access. The following is implemented using fastify
as the adapter for NestJS applications.
NestJS itself is an abstraction layer, and we can get the instance of the source server, here it is fastify
.
According to the tRPC documentation , we inject a plug-in into the fastify
instance to complete the tRPC access.
First install the necessary libraries for tRPC:
pnpm add @trpc/server zod
Create a new NestJS module and name it trpc
.
nest g mo src/processors/trpc --no-spec nest gs src/processors/trpc --no-spec
Mark Module as a global module. It will be used later when defining tRPC router in other business modules.
@Module({ exports: [tRPCService], providers: [tRPCService], }) @Global() export class tRPCModule {}
Create a trpc.instance.ts
for instantiating tRPC. Here, the instance needs to be created externally. This does not seem to be in line with the Angular philosophy, but this instance type needs to be external. There is no other way.
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() // ====== 以下的类型导出是为Client 端侧使用的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']
Above we created the tRPC instance and injected it with the authorization header required for authentication.
Come 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( // 这里我们也定义身份验证的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, }) }) } }
Above we defined the tRPC router. Now we need to do some operations on the fastify app.
First inject tRPCModule
into AppModule
, now get the fastify instance in bootstrap and then apply applyMiddleware
method of tRPCService
.
::: warning
Here I did not use the method mentioned in the document, the reason is https://github.com/trpc/trpc/issues/4820.
:::
Come 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)
In this way, a simple tRPC framework is over, but our progress has just begun.
Now you can access /trpc/user.list
to get the following results.
{ "result": { "data": [] } }
Define tRPC Router by business module
Expect
Obviously, writing all routers in one file does not conform to the modular idea, so in this section we split it up to conform to the NestJS philosophy as much as possible.
First, we need to clarify what we want to do. Our expectation is to store a file called some-biz.trpc.ts
under each business module to define tRPC routing. Naming similar to some-biz.controller.ts
.
For example, we define tRPC routing 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 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 {}
Collect dependencies
The above is the way we expected, so if tRPCService is written like this, how can it automatically collect dependencies?
DiscoveryModule
of @nestjs/core
provides us with this method.
First, transform 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], // 注入DiscoveryModule }) @Global() export class tRPCModule {}
Then modify createAppRouter
in trpc.service.ts
, use DiscoveryService
to collect all dependencies and extract the trpcRouter we need. So how to extract it? Here we use Reflector to obtain the original information, and then filter it based on this.
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 the original message, there must be room for decoration. Our expectation is to add @TRPCRouter
decorator to 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 put this constant value anywhere.
Then add decoration to 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 TRPCModule
to the top-level Module.
::: error
Note here that TRPCModule
needs to collect dependencies on each module. Please ensure that TRPCModule
is loaded after each module.
@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 careful, you will find that I use defineTrpcRouter
. What is this? This is also discussed below.
type safety
Now we have modified the sub-module definition tRPC router and used fully automatic collection of dependencies, resulting in the type being sent.
Above we used mergeRouters
as to create an any. In this step we need to define the any type.
Next is broadcast gymnastics, which is not necessary. The code is posted 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 you still need to import each tRPC Router class, but it is just a type. I don’t know if there is any other better way to omit this step.
Modify creatEAppRouter
method:
- const appRouter = tRpc.mergeRouters(...(routers as any)) + const appRouter = tRpc.mergeRouters(...(routers as any as Routers))
By the way, as for defineTrpcRouter
above, it is to solve some situations where tRPC.router
is deeply nested. In the end, it also serves the type, so I won’t expand on it here. code show as below:
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 // 干净了很多return defineTrpcRouter('user', { user: t.query(() => []), getCurrentUser: t.query(() => this.service.getCurrentUser()), }) } }
You’re done. Next, you need to connect to the Client side.
Access the Client side
NestJS is not a full-stack framework. Although tRPC can provide a complete communication interface, it is still not possible to get a complete type instance on the client side.
If your client-side code happens to be in the same monorepo as NestJS, then we continue on. If not, please change it to monorepo form first.
Our directory structure now 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"] }
vite.config.ts
can also add alias.
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' // 从NestJS 项目中引用,这里之前都导出了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`, // 填服务的地址async headers() { return { authorization: getToken()!, // 鉴权头} }, }), ], }) export type ReactQueryOptions = inferReactQueryProcedureOptions<AppRouter> export type { RouterInputs, RouterOutputs }
Mount components at the top level:
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.
finish watching? say something
This article is reproduced from: https://innei.in/posts/programming/nestjs-with-trpc-and-dependency-injection
This site is only for collection, and the copyright belongs to the original author.