Перейти до основного вмісту

@ditsmod/trpc

Модуль @ditsmod/trpc забезпечує інтеграцію з @trpc/server. Готовий приклад застосунку з @ditsmod/trpc можна проглянути у репозиторії Ditsmod. Там ви можете знайти приклади застосування ґардів та інтерсепторів.

Швидкий старт

Ви також можете скористатись моно-репозиторієм, в якому є мінімальний код для швидкого старту:

git clone --depth 1 https://github.com/ditsmod/trpc-monorepo-starter.git

Як формуються типи для клієнта на рівні модуля

Ditsmod намагається бути прозорим для @trpc/client надаючи можливість TypeScript виводити типи зі статичного коду, без необхідності додаткової компіляції для клієнта. Кожен модуль, що надає конфігурацію для tRPC-роутера, повинен це робити у методі getRouterConfig():

import { featureModule } from '@ditsmod/core';
import { initTrpcModule, ModuleWithTrpcRoutes } from '@ditsmod/trpc';
import { RouterOf } from '@ditsmod/trpc/client';

import { CommentModule } from './comments/comment.module.js';
import { PostController } from './post.controller.js';

// For TRPCClient
export type PostRouter = RouterOf<typeof PostModule>;

@initTrpcModule({
imports: [CommentModule],
controllers: [PostController],
})
@featureModule()
export class PostModule implements ModuleWithTrpcRoutes {
getRouterConfig() {
return {
post: {
createPost: PostController.prototype.createPost, // Pointed to a controller
comments: CommentModule.prototype.getRouterConfig, // Pointed to a module
},
};
}
}

Тут ModuleWithTrpcRoutes - це інтерфейс, який гарантує наявність методу getRouterConfig() у даному модулі.

В даному прикладі, показано конфіг, на основі якого буде створено:

  1. роут post.createPost, за який відповідатиме метод контролера - PostController.prototype.createPost;
  2. група роутів post.comments, за який відповідатиме імпортований модуль - CommentModule.prototype.getRouterConfig. Можна здогадатись, що CommentModule має свій метод getRouterConfig(), в якому вже уточнюється які саме контролери створюють певні роути.

Зверніть увагу, що тут створюється тип PostRouter для tRPC-клієнта. Це рекомендується робити для кожного невкладеного(!) модуля, щоб пом'якшити проблеми з TypeScript-перформенсом, коли він виводить типи зі складних моделей. Але пам'ятайте, що такі типи не будуть коректно працювати для вкладених модулів. У даному прикладі CommentModule є вкладеним, тому для нього не доцільно робити export type CommentsRouter = RouterOf<typeof CommentsModule>.

Також ви можете централізовано виводити єдиний тип для змердженого tRPC-роутера на рівні застосунку, але це рекомендується робити лише у випадку, якщо у вас немає планів створювати складні моделі, аналізуючи які TypeScript буде "помирати". Щоб централізовано вивести єдиний роутер на увесь застосунок, треба скористатись AppRouterHelper:

import { rootModule } from '@ditsmod/core';
import type { SetAppRouterOptions, TrpcCreateOptions, TrpcRootModule } from '@ditsmod/trpc';
import type { AppRouterHelper } from '@ditsmod/trpc/client';

import { PostModule } from '#post/post.module.js';
import { AuthModule } from '#auth/auth.module.js';
import { MessageModule } from '#message/message.module.js';

const modulesWithTrpcRoutes = [AuthModule, PostModule, MessageModule] as const;
export type AppRouter = AppRouterHelper<typeof modulesWithTrpcRoutes>;

@rootModule({
imports: [...modulesWithTrpcRoutes],
})
export class AppModule implements TrpcRootModule {
setTrpcCreateOptions(): TrpcCreateOptions {
return {
// Passing options for initTRPC.create()
};
}

setAppRouterOptions(): SetAppRouterOptions {
return {
basePath: '/trpc/',
};
}
}

Зверніть увагу, що у AppRouterHelper передається не просто масив імпортованих модулів, а цей масив ще й позначено за допомогою as const - це важлива умова, без якої AppRouterHelper працюватиме некоректно.

Також зверніть увагу на інтерфейс TrpcRootModule, який вимагає обов'язкового впровадження методу setAppRouterOptions(), також опціонально можна імплементувати setTrpcCreateOptions(). Коли ваш метод setAppRouterOptions() повертає конфіг для роутера, ви не зможете передати опцію createContext, оскільки Ditsmod автоматично створює контекст у вигляді об'єкту { req, res } щоб гарантувати доступність цих змінних в контексті. Звичайно ж, в процедурах ви можете додавати будь-які інші властивості контекста.

Як формуються типи для клієнта на рівні метода контролера

Кожен метод контролера, що створює роут, повинен мати декоратор trpcRoute та повинен поверти tRPC-процедуру:

import { trpcController, TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { z } from 'zod';

@trpcController()
export class PostController {
@trpcRoute()
createPost(routeService: TrpcRouteService) {
return routeService.procedure.input(z.object({ title: z.string() })).mutation(({ input }) => {
return { ...input, id: 1, body: 'post text' };
});
}
}

Тобто, якщо вам достатньо користуватись перевагами DI на рівні роуту (а не на рівні HTTP-запиту), то ваш код буде мало відрізнятись від нативного tRPC-коду. Практично єдина відмінність лише у тому, що вам початкову процедуру потрібно брати із TrpcRouteService, як показано у даному прикладі. До речі, TrpcRouteService можна вказувати тип контексту та інпуту - TrpcRouteService<SomeContext, SomeInput>. Майте на увазі, що якщо ви збираєтесь у коді писати routeService.procedure.input(...), то вам не потрібно передавати другий дженерік, бо типи інпутів будуть конфліктувати. Другий дженерік є сенс використовувати у парі з routeService.procedureAfterInput, який варто використовувати у випадку, якщо валідацію ви робите автоматично в інтерсепторах, а не безпосередньо у коді роуту.

Окрім TrpcRouteService, у параметрах методу контролера ви можете запитувати будь-який інший сервіс на рівні роуту, причому порядок параметрів не має значення:

@trpcController()
export class PostController {
@trpcRoute()
createPost(service1: Service1, service2: Service2, routeService: TrpcRouteService) {
// ...
}
}

У випадку, якщо вам потрібно буде використовувати ґарди чи інтерсептори, вам достатньо їх додати відповідно у перший та другий масив у декораторі trpcRoute:

import { trpcController, TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { z } from 'zod';

import { BearerGuard } from '../auth/bearer.guard.js';
import { MyInterceptor } from './my.interceptor.js';

@trpcController()
export class PostController {
@trpcRoute([BearerGuard], [MyInterceptor])
createPost(routeService: TrpcRouteService) {
return routeService.procedure.input(z.object({ title: z.string() })).mutation(({ input }) => {
return { ...input, id: 1, body: 'post text' };
});
}
}

Як користуватись типами роутера на клієнті

Як було сказано вище, щоб пом'якшити проблеми з TypeScript-перформенсом, рекомендується виводити тип для кожного невкладеного(!) модуля. Під "невкладеним модулем" мається на увазі той модуль, який безпосередньо імпортується у кореневий модуль. Отже, під час створення клієнта, рекомендується використовувати узагальнений тип для роутера - createTRPCClient<AnyTRPCRouter>(), а потім уточнювати тип для кожного окремого модуля:

import type { AnyTRPCRouter } from '@trpc/server';
import { createTRPCClient, TRPCClient } from '@trpc/client';
import type { PostRouter } from 'server'; // You can give this name in your monorepo

const trpc = createTRPCClient<AnyTRPCRouter>({
// ...
});

const postClient = trpc as TRPCClient<PostRouter>;
const post = await postClient.post.createPost.mutate({ title: 'hello client' });

Використання DI для провайдерів на рівні HTTP-запиту

Коли ви пишете наступний код, DI вам забезпечить роботу провайдерів на рівні роуту:

@trpcController()
export class PostController {
constructor(service1: Service1) {}

@trpcRoute()
listPosts(service2: Service2, routeService: TrpcRouteService) {
return routeService.procedure.query(() => this.service1.messages);
}
}

В даному прикладі, DI вирішить залежність для Service1, Service2 та TrpcRouteService на рівні роуту. Якщо ж ви хочете щоб DI працював також на рівні HTTP-запиту, вам треба зробити три кроки:

  1. Створити ClassFactoryProvider, який буде працювати на рівні запиту.
  2. Передати новостворений провайдер до DI на рівні запиту.
  3. Використати новостворений провайдер з одним із методів TrpcRouteService, ім'я якого має префікс di (наприклад, diQuery, diMutation і т.д.).

Давайте пройдемо ці кроки разом.

Крок перший

import { injectable, factoryMethod } from '@ditsmod/core';
import { opts, TrpcOpts } from '@ditsmod/trpc';

import { DbService } from '#db/db.service.js';
import { InputPost } from '#models/post.js';


@injectable()
export class PostService {
@factoryMethod()
method1(@opts opts: TrpcOpts<any, InputPost>, db: DbService) {
// ...
return posts;
}
}

Зверніть увагу, що на рівні методу даний провайдер має декоратор, причому не важливо який саме, головне, щоб він створювався за допомогою відповідних Ditsmod-хелперів.

Крок другий

Найпростіше передати ClassFactoryProvider до DI - за допомогою хелпера Providers:

import { trpcController } from '@ditsmod/trpc';
import { Providers } from '@ditsmod/core';
import { PostService } from '#post/post.service.js';
// ...
@trpcController({
providersPerReq: new Providers().useFactories(PostService),
})
export class PostController {
// ...
}

До речі, в даному прикладі провайдери передаються у метадані контролера, але їх також можна передавати і у метадані модуля на рівні запиту.

Метод providers.useFactories() автоматично сканує наявність методів з декораторами у переданому класі, і для кожного методу створює відповідний провайдер. Наприклад, якщо ви передаєте providers.useFactories(PostService), і у PostService є три методи з декораторами на рівні методу, то providers.useFactories(PostService) передасть до DI приблизно такі провайдери:

[
{ useFactory: [PostService, PostService.prototype.method1] },
{ useFactory: [PostService, PostService.prototype.method2] },
{ useFactory: [PostService, PostService.prototype.method3] },
]

Крок третій

Після того, як провайдери передані до DI, їх можна використовувати у наступній формі:

import { TrpcRouteService, trpcRoute } from '@ditsmod/trpc';
import { PostService } from '#post/post.service.js';
//...
export class PostController {
@trpcRoute()
listPosts(routeService: TrpcRouteService) {
return routeService.diQuery(PostService.prototype.method1);
}
}

Тобто, метод routeService.diQuery() приймає метод контролера, а DI у реєстрі шукає провайдера з таким токеном, і повертає його значення. В такому разі, під час кожного запиту буде створюватись інстанс PostService та викликатись його метод method1.