tRPC
tRPC позволяет разрабатывать полностью безопасные с точки зрения типов API для клиент-серверных приложений (предпочтительной является архитектура монорепозитория). Это посредник между сервером и клиентом, позволяющий им использовать один маршрутизатор (роутер) для обработки запросов HTTP. Использование одного роутера, в свою очередь, обуславливает возможность автоматического вывода типов (type inference) входящих и исходящих данных (input/output), что особенно актуально для клиента и позволяет избежать дублирования типов или использования общих (shared) типов.
tRPC состоит из нескольких отдельных пакетов, основные из которых рассматриваются ниже.
@trpc/server
npm i @trpc/server
# или
yarn add @trpc/server
Определение роутеров / routers
Разработка интерфейса (API), основанного на tRPC, начинается с определения роутера.
Инициализация tPRC
Важно: tRPC должен инициализироваться только один раз.
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const router = t.router;
export const middleware = t.middleware;
export const publicProcedure = t.procedure;
Определение роутера
Определяем конечную точку интерфейса:
import * as trpc from '@trpc/server';
import { publicProcedure, router } from './trpc';
const appRouter = router({
greeting: publicProcedure.query(() => 'hello tRPC v10!'),
});
// Экспортируем только тип маршрутизатора!
// Это предотвращает импорт серверного кода на клиенте
export type AppRouter = typeof appRouter;
Продвинутое использование
При инициализации маршрутизатора можно делать следующее:
- настраивать контексты запросов
- добавлять метаданные в процедуры
- форматировать и обрабатывать ошибки
- преобразовывать данные
- кастомизировать настройку выполнения кода (runtime)
Для кастомизации объекта t
при инициализации может использоваться цепочка методов, например:
import { Context, Meta } from '@trpc/server';
const t = initTRPC.context<Context>().meta<Meta>().create({
// ...
});
Настройка выполнения кода
export interface RuntimeConfig<TTypes extends RootConfigTypes> {
/**
* Преобразователь данных
* @link https://trpc.io/docs/data-transformers
*/
transformer: TTypes['transformer'];
/**
* Редактор ошибок
* @link https://trpc.io/docs/error-formatting
*/
errorFormatter: ErrorFormatter<TTypes['ctx'], any>;
/**
* Позволяет запускать `@trpc/server` в несерверных окружениях,
* например, в тестах
* @default false
*/
allowOutsideOfServer: boolean;
/**
* Индикатор выполнения кода на сервере
* @default typeof window === 'undefined' || 'Deno' in window || process.env.NODE_ENV === 'test'
*/
isServer: boolean;
/**
* Индикатор выполнения кода в режиме разработки
* Используется для определения необходимости возврата трассировки стека
* @default process.env.NODE_ENV !== 'production'
*/
isDev: boolean;
}
Определение процедур / procedures
Процедура - это очень гибкий примитив для создания серверных функций. Для создания процедуры используется паттерн "Строитель" (builder), что позволяет определять переиспользуемые (reusable) процедуры для разных частей сервера.
Пример без валидации входных данных
import { router, publicProcedure } from './trpc';
import { z } from 'zod';
const appRouter = router({
// Создаем открытую процедуру для пути `hello`
hello: publicProcedure.query(() => {
return {
greeting: 'всем привет',
};
}),
});
Валидация входных данных
tRPC поддерживает yup/superstruct/zod/myzod/кастомные валидаторы. Пример использования Zod:
import { publicProcedure, router } from './trpc';
import { z } from 'zod';
export const appRouter = router({
hello: publicProcedure
// Входные данные должны быть опциональным объектом,
// содержащий поле `name` со строковым значением
.input(
z
.object({
name: z.string(),
})
.optional(),
)
.query(({ input }) => {
return {
greeting: `привет, ${input?.name ?? 'народ'}`,
};
}),
});
export type AppRouter = typeof appRouter;
Несколько валидаторов входных данных
Валидаторы могут "выстраиваться" в цепочку:
// @filename: roomProcedure.ts
import { publicProcedure } from './trpc';
import { z } from 'zod';
/**
* Создаем переиспользуемую открытую процедуру для комнаты чата
*/
export const roomProcedure = publicProcedure.input(
z.object({
roomId: z.string(),
}),
);
// @filename: _app.ts
import { router } from './trpc';
import { roomProcedure } from './roomProcedure';
import { z } from 'zod';
const appRouter = router({
sendMessage: roomProcedure
// Добавляем дополнительную валидацию входных данных для процедуры `sendMessage`
.input(
z.object({
text: z.string(),
}),
)
.mutation(({ input }) => {
// ...
}),
});
export type AppRouter = typeof appRouter;
Несколько процедур
Для добавления нескольких процедур достаточно определить их в виде полей объекта, передаваемого в t.router()
:
import { initTRPC } from '@trpc/server';
export const t = initTRPC.create();
const router = t.router;
const publicProcedure = t.procedure;
export const appRouter = router({
hi: publicProcedure.query(() => {
return {
text: 'всем привет',
};
}),
bye: publicProcedure.query(() => {
return {
text: 'всем пока',
};
}),
});
export type AppRouter = typeof appRouter;
Переиспользуемые базовые процедуры
Пример создания набора защищенных процедур авторизации для приложения Next.js:
// @filename: context.ts
import { inferAsyncReturnType } from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
/**
* Создаем контекст для входящего запроса
* @link https://trpc.io/docs/context
*/
export async function createContext(opts: trpcNext.CreateNextContextOptions) {
const session = await getSession({ req: opts.req });
return {
session,
};
};
export type Context = inferAsyncReturnType<typeof createContext>;
// @filename: trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
const t = initTRPC.context<Context>().create();
/**
* Переиспользуемый посредник, проверяющий состояние аутентификации пользователя
**/
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.session?.user?.email) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
return next({
ctx: {
// `session` имеет ненулевое значение (non-nullable)
session: ctx.session,
},
});
});
export const middleware = t.middleware;
export const router = t.router;
/**
* Открытая процедура
**/
export const publicProcedure = t.procedure;
/**
* Защищенная процедура
**/
export const protectedProcedure = t.procedure.use(isAuthed);
// @filename: _app.ts
import { protectedProcedure, publicProcedure, router } from './trpc';
import { z } from 'zod';
export const appRouter = router({
createPost: protectedProcedure
.mutation(({ ctx }) => {
const session = ctx.session;
// ...
}),
whoami: publicProcedure
.query(({ ctx }) => {
const session = ctx.session;
// ...
}),
});
Объединение роутеров
Объединение дочерних роутеров
// @filename: trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const middleware = t.middleware;
export const router = t.router;
export const publicProcedure = t.procedure;
// @filename: routers/_app.ts
import { router } from '../trpc';
import { z } from 'zod';
import { userRouter } from './user';
import { postRouter } from './post';
const appRouter = router({
user: userRouter, // процедуры пространства (namespace) `user`
post: postRouter, // процедуры пространства `post`
});
export type AppRouter = typeof appRouter;
// @filename: routers/post.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
create: publicProcedure
.input(
z.object({
title: z.string(),
}),
)
.mutation(({ input }) => {
// ...
}),
list: publicProcedure.query(() => {
// ...
return [];
}),
});
// @filename: routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
list: publicProcedure.query(() => {
// ...
return [];
}),
});
Объединение с помощью t.mergeRouters()
Метод t.mergeRouters
позволяет объединять роутеры в одно пространство:
// @filename: routers/_app.ts
import { router, publicProcedure, mergeRouters } from '../trpc';
import { z } from 'zod';
import { userRouter } from './user';
import { postRouter } from './post';
const appRouter = mergeRouters(userRouter, postRouter)
export type AppRouter = typeof appRouter;
// @filename: routers/post.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const postRouter = router({
postCreate: publicProcedure
.input(
z.object({
title: z.string(),
}),
)
.mutation(({ input }) => {
// ...
}),
postList: publicProcedure.query(() => {
// ...
return [];
}),
});
// @filename: routers/user.ts
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
userList: publicProcedure.query(() => {
// ...
return [];
}),
});
Контекст / context
Контекст содержит данные для процедур, такие как соединение с базой данных или информация об аутентификации.
Установка контекста состоит из 2 шагов: определение типа при инициализации и создание контекста выполнения для каждого запроса.
Определение типа контекста
Тип контекста определяется с помощью initTRPC.context<TContext>()
перед .create()
. Тип TContext
может выводиться из типа возвращаемого функцией значения или определяться явно.
import { initTRPC, type inferAsyncReturnType } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
export const createContext = async (opts: CreateNextContextOptions) => {
const session = await getSession({ req: opts.req });
return {
session,
};
};
const t1 = initTRPC.context<typeof createContext>().create();
t1.procedure.use(({ ctx }) => {
// ...
});
type Context = inferAsyncReturnType<typeof createContext>;
const t2 = initTRPC.context<Context>().create();
t2.procedure.use(({ ctx }) => {
// ...
});
Создание контекста
Функция createContext
передается обработчику запросов, который монтирует роутер приложения, через HTTP, вызов на стороне сервера (server-side call) или помощник SSG (SSG helper).
// 1. Запрос HTTP
import { createHTTPHandler } from '@trpc/server/adapters/standalone';
import { createContext } from './context';
import { appRouter } from './router';
const handler = createHTTPHandler({
router: appRouter,
createContext,
});
// 2. Вызов на стороне сервера
import { createContext } from './context';
import { appRouter } from './router';
const caller = appRouter.createCaller(await createContext());
// 3. Помощник SSG
import { createProxySSGHelpers } from '@trpc/react-query/ssg';
import { createContext } from './context';
import { appRouter } from './router';
const ssg = createProxySSGHelpers({
router: appRouter,
ctx: await createContext(),
});
Пример
// @filename: context.ts
import type { inferAsyncReturnType } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { getSession } from 'next-auth/react';
/**
* Создаем контекст для входящего запроса
* @link https://trpc.io/docs/context
*/
export async function createContext(opts: CreateNextContextOptions) {
const session = await getSession({ req: opts.req });
return {
session,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
// @filename: trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { Context } from './context';
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.session?.user?.email) {
throw new TRPCError({
code: 'UNAUTHORIZED',
});
}
return next({
ctx: {
// `session` имеет ненулевое значение
session: ctx.session,
},
});
});
export const middleware = t.middleware;
export const router = t.router;
/**
* Открытая процедура
*/
export const publicProcedure = t.procedure;
/**
* Защищенная процедура
*/
export const protectedProcedure = t.procedure.use(isAuthed);
Внутренний и внешний контексты
Иногда имеет смысл разделять контекст на внутренний и внешний.
Внутренний контекст - это контекст, который не зависит от запроса, например, соединение с БД. Соответственно, внешний контекст - это контекст, который зависит от запроса. Такой контекст доступен только для процедур, обращение к которым происходит через HTTP.
import type { inferAsyncReturnType } from '@trpc/server';
import type { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { type Session, getSessionFromCookie } from './auth';
/**
* Определяем форму (shape) внутреннего контекста
*/
interface CreateInnerContextOptions extends Partial<CreateNextContextOptions> {
session: Session | null;
}
/**
* Внутренний контекст. Доступен во всех процедурах,
* что может быть полезным для:
* - тестирования: не нужно имитировать (mock) `req/res` Next.js
* - вызова `createSSGHelpers()` tRPC, где отсутствует `req/res`
*
* @see https://trpc.io/docs/context#inner-and-outer-context
*/
export async function createContextInner(opts?: CreateInnerContextOptions) {
return {
prisma,
session: opts.session,
};
}
/**
* Внешний контекст
*
* @see https://trpc.io/docs/context#inner-and-outer-context
*/
export async function createContext(opts: CreateNextContextOptions) {
const session = getSessionFromCookie(opts.req);
const contextInner = await createContextInner({ session });
return {
...contextInner,
req: opts.req,
res: opts.res,
};
}
export type Context = inferAsyncReturnType<typeof createContextInner>;
Для того, чтобы не проверять наличие req
и res
для каждой процедуры, можно определить такую переиспользуемую процедуру:
export const apiProcedure = publicProcedure.use((opts) => {
if (!opts.ctx.req || !opts.ctx.res) {
throw new Error('В вызове отсутствует `req` или `res`.');
}
return opts.next({
ctx: {
// Перезаписываем контекст истинными `req` и `res`,
// что также перезаписывает типы, используемые в процедуре
req: opts.ctx.req,
res: opts.ctx.res,
},
});
});
Обработчик запросов / API handler
tRPC - это не сервер как таковой, он "живет" внутри сервера, такого как Next.js или Express. Несмотря на это, большая част ь возможностей и синтаксиса tRPC является одинаковой для всех серверов. Это возможно благодаря обработчику запросов или адаптеру, который "приклеивает" запросы HTTP к серверу и tRPC.
Обработчик запросов "сидит" на роуте (обычно, /api/trpc
, но это лишь соглашение) и обрабатывает запросы для этого роута и его потомков. Он получает запрос от сервера, использует функцию createContext
для генерации контекста и затем передает запрос и контекст процедуре, определенной в роуте.
Он также принимает некоторые опциональные параметры, такие как onError()
- функцию обратного вызова для обработки ошибок, возникающих в процедуре.
Пример использования адаптера для Next.js
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createContext } from '../../../server/trpc/context';
import { appRouter } from '../../../server/trpc/router/_app';
// Обработчик запросов
export default createNextApiHandler({
router: appRouter, // корневой роутер, see https://trpc.io/docs/procedures
createContext, // контекст запросов, see https://trpc.io/docs/context
});
Продвинутое использование
Обработчик запросов, создаваемый с помощью createNextApiHandler()
, это просто функция, которая принимает объекты req
и res
. Это означает, что данные объекты можно модифицировать перед их передачей обработчику, например, для включения CORS:
import { createNextApiHandler } from '@trpc/server/adapters/next';
import { createContext } from '../../../server/trpc/context';
import { appRouter } from '../../../server/trpc/router/_app';
// Создаем обработчик, но пока не возвращаем его
const nextApiHandler = createNextApiHandler({
router: appRouter,
createContext,
});
// @see https://nextjs.org/docs/api-routes/introduction
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
) {
// Модифицируем объекты `req` и `res`
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Request-Method', '*');
res.setHeader('Access-Control-Allow-Methods', 'OPTIONS, GET');
res.setHeader('Access-Control-Allow-Headers', '*');
if (req.method === 'OPTIONS') {
res.writeHead(200);
return res.end();
}
// Передаем модифицированные `req/res` обработчику
return nextApiHandler(req, res);
}
tRPC предоставляет несколько официальных адаптеров.
Посредники / middlewares
Метод t.procedure.use
позволяет добавлять в процедуру посредников. Посредник оборачивает (wrap) вызов процедуры и передает ей возвращаемое им значение.
Авторизация
В следующем примере перед выполнением процедуры adminProcedure
проверяется, что пользователь является администратором:
import { TRPCError, initTRPC } from '@trpc/server';
interface Context {
user?: {
id: string;
isAdmin: boolean;
// ...
};
}
const t = initTRPC.context<Context>().create();
export const middleware = t.middleware;
export const publicProcedure = t.procedure;
export const router = t.router;
const isAdmin = middleware(async ({ ctx, next }) => {
if (!ctx.user?.isAdmin) {
// См. раздел, посвященный обработке ошибок
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx });
});
export const adminProcedure = publicProcedure.use(isAdmin);
import { adminProcedure, publicProcedure, router } from './trpc';
const adminRouter = router({
secretPlace: adminProcedure.query(() => 'ключ'),
});
export const appRouter = router({
foo: publicProcedure.query(() => 'bar'),
admin: adminRouter,
});
Логгирование
В следующем примере автоматически логгируется время выполнения запроса:
const loggerMiddleware = middleware(async ({ path, type, next }) => {
const start = performance.now();
const result = await next();
const durationMs = performance.now() - start;
result.ok
? logMock('Время выполнения успешного запроса:', { path, type, durationMs })
: logMock('Время выполнения неудачного запроса:', { path, type, durationMs });
return result;
});
export const loggedProcedure = publicProcedure.use(loggerMiddleware);
Модификация контекста
В следующем примере посредник модифицирует свойства контекста, передаваемого процедурам:
type Context = {
// `user` может быть не определен (nullable)
user?: {
id: string;
};
};
const isAuthed = middleware(({ ctx, next }) => {
// `ctx.user` не определен
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
// `user` имеет ненулевое значение
user: ctx.user,
},
});
});
const protectedProcedure = publicProcedure.use(isAuthed);
protectedProcedure.query(({ ctx }) => ctx.user);
Вызовы из сервера
Функция createCaller
возвращает экземпляр RouterCaller
, способного выполнять запросы и мутации и позволяющего вызывать процедуры прямо из сервера.
Важно: RouterCaller
не должен использоваться для вызова процедур из других процедур.
Пример запроса
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const router = t.router({
// Создаем процедуру для пути `greeting`
greeting: t.procedure
.input(z.object({ name: z.string() }))
.query(({ input }) => `привет, ${input.name}`),
});
// В качестве параметра `createCaller()` принимает контекст
const caller = router.createCaller({});
const result = await caller.greeting({ name: 'народ' });
Пример мутации
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const posts = ['Один', 'Два', 'Три'];
const t = initTRPC.create();
const router = t.router({
post: t.router({
add: t.procedure.input(z.string()).mutation(({ input }) => {
posts.push(input);
return posts;
}),
}),
});
const caller = router.createCaller({});
const result = await caller.post.add('Четыре');
Пример контекста с посредником
import { TRPCError, initTRPC } from '@trpc/server';
type Context = {
user?: {
id: string;
};
};
const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Вы не авторизованы.',
});
}
return next({ ctx });
});
const protectedProcedure = t.procedure.use(isAuthed);
const router = t.router({
secret: protectedProcedure.query(({ ctx }) => ctx.user),
});
{
// ❌ будет выброшено исключение, поскольку в контексте отсутствует `user`
const caller = router.createCaller({});
const result = await caller.secret();
}
{
// ✅
const authorizedCaller = router.createCaller({
user: {
// ...
},
});
const result = await authorizedCaller.secret();
}
Пример конечной точки Next.js
import { TRPCError } from '@trpc/server';
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
import type { NextApiRequest, NextApiResponse } from 'next';
import { appRouter } from '~/server/routers/_app';
type ResponseData = {
data?: {
postTitle: string;
};
error?: {
message: string;
};
};
export default async (
req: NextApiRequest,
res: NextApiResponse<ResponseData>,
) => {
// Идентификатор поста, которого не существует в БД
const postId = `this-id-does-not-exist-${Math.random()}`;
const caller = appRouter.createCaller({});
try {
const postResult = await caller.post.byId({ id: postId });
res.status(200).json({ data: { postTitle: postResult.title } });
} catch (cause) {
// Если это ошибка tRPC, из нее можно извлечь дополнительную информацию
if (cause instanceof TRPCError) {
const httpStatusCode = getHTTPStatusCodeFromError(cause);
res.status(httpStatusCode).json({ error: { message: cause.message } });
return;
}
res.status(500).json({
error: { message: `Ошибка получения данных поста с ID ${postId}` },
});
}
};
Авторизация
Функция createContext
вызывается для каждого входящего запроса, так что это подходящее место для добавления контекстуальной информации о пользователя в объект запроса.
Создание контекста из заголовков запроса
import * as trpc from '@trpc/server';
import { inferAsyncReturnType } from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
import { decodeAndVerifyJwtToken } from '../utils';
export async function createContext({
req,
res,
}: trpcNext.CreateNextContextOptions) {
// Создаем контекст на основе `req`
// Контекст доступен во всех процедурах как объект `ctx`
async function getUserFromHeader() {
if (req.headers.authorization) {
const user = await decodeAndVerifyJwtToken(
// Authorization: 'Bearer [token]'
req.headers.authorization.split(' ')[1],
);
return user;
}
return null;
}
const user = await getUserFromHeader();
return {
user,
};
}
export type Context = inferAsyncReturnType<typeof createContext>;
Авторизация с помощью процедуры (резолвера/resolver)
import { TRPCError, initTRPC } from '@trpc/server';
import type { Context } from '../context';
export const t = initTRPC.context<Context>().create();
const appRouter = t.router({
public: t.procedure
.input(z.string().nullish())
.query(({ input, ctx }) => `привет, ${input ?? ctx.user?.name ?? 'народ'}`),
protected: t.procedure.query(({ ctx }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return {
super: 'secret'
};
}),
});
Авторизация с помощью посредника
import { TRPCError, initTRPC } from '@trpc/server';
export const t = initTRPC.context<Context>().create();
const isAuthed = t.middleware(({ next, ctx }) => {
if (!ctx.user?.isAdmin) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user,
},
});
});
// Переиспользуемый защитник
export const protectedProcedure = t.procedure.use(isAuthed);
t.router({
public: t.procedure
.input(z.string().nullish())
.query(({ input, ctx }) => `hello ${input ?? ctx.user?.name ?? 'world'}`),
admin: t.router({
protected: protectedProcedure.query(({ ctx }) => {
return {
super: 'secret',
};
}),
}),
});
Валидация результата
Для валидации результата, возвращаемого резолвером (например, t.procedure.query()
) используется метод output
, который работает аналогично методу input
, предназначенному для валидации входных данных.
Обратите внимание: как правило, валидация результата не требуется.
Пример использования Zod
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
export const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.output(
z.object({
greeting: z.string(),
}),
)
// Ожидаемым типом результата является `{ greeting: string }`
.query(() => {
return {
greeting: 'привет',
};
}),
});
export type AppRouter = typeof appRouter;
Пример использования кастомного валидатора
import { initTRPC } from '@trpc/server';
export const t = initTRPC.create();
export const appRouter = t.router({
hello: t.procedure
.output((value: any) => {
if (value && typeof value.greeting === 'string') {
return { greeting: value.greeting };
}
throw new Error('Приветствие не обнаружено.');
})
.query(() => {
return {
greeting: 'привет',
};
}),
});
export type AppRouter = typeof appRouter;
Вывод типов
Иногда бывает полезным оборачивать интерфейс @trpc/client
или @trpc/react-query
другими функциями. В этом случае необходимо каким-то образом выводить типы, генерируемые роутером @trpc/server
.
Помощники вывода типов
@trpc/server
экспортирует следующие утилиты для вывода типов AppRouter
:
inferRouterInputs<TRouter>
inferRouterOutputs<TRouter>
Предположим, что у нас имеется такой роутер:
// @filename: server.ts
import { initTRPC } from '@trpc/server';
import { z } from "zod";
const t = initTRPC.create();
const appRouter = t.router({
post: t.router({
list: t.procedure
.query(() => {
// Обращение к БД
return [{ id: 1, title: 'tRPC лучший!' }];
}),
byId: t.procedure
.input(z.string())
.query(({ input }) => {
// Обращение к БД
return { id: 1, title: 'tRPC лучший!' };
}),
create: t.procedure
.input(z.object({ title: z.string(), text: z.string(), }))
.mutation(({ input }) => {
// Обращение к БД
return { id: 1, title: 'tRPC лучший!' };
}),
}),
});
export type AppRouter = typeof appRouter;
Типы из этого роутера можно вывести следующим образом:
// @filename: client.ts
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from './server';
type RouterInput = inferRouterInputs<AppRouter>;
type RouterOutput = inferRouterOutputs<AppRouter>;
type PostCreateInput = RouterInput['post']['create'];
type PostCreateOutput = RouterOutput['post']['create'];
Вывод TRPClientError
// @filename: client.ts
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from './server';
import { trpc } from './trpc';
export function isTRPCClientError(
cause: unknown,
): cause is TRPCClientError<AppRouter> {
return cause instanceof TRPCClientError;
}
async function main() {
try {
await trpc.post.byId.query('1');
} catch (cause) {
if (isTRPCClientError(cause)) {
// `cause` типизирована как `TRPCClientError`
console.log('Данные:', cause.data);
} else {
// ...
}
}
}
main();
Вывод настроек React Query
// @filename: trpc.ts
import {
type inferReactQueryProcedureOptions,
createTRPCReact
} from '@trpc/react-query';
import type { inferRouterInputs } from '@trpc/server';
import type { AppRouter } from './server';
export type ReactQueryOptions = inferReactQueryProcedureOptions<AppRouter>;
export type RouterInputs = inferRouterInputs<AppRouter>;
export const trpc = createTRPCReact<AppRouter>();
// @filename: usePostCreate.ts
import { type ReactQueryOptions, trpc } from './trpc';
type PostCreateOptions = ReactQueryOptions['post']['create'];
function usePostCreate(options?: PostCreateOptions) {
const utils = trpc.useContext();
return trpc.post.create.useMutation({
...options,
onSuccess(post) {
// Инвалидируем все запросы роутера постов
// после создания нового поста
utils.post.invalidate();
options?.onSuccess?.(post);
},
});
}
// @filename: usePostById.ts
import { ReactQueryOptions, RouterInputs, trpc } from './trpc';
type PostByIdOptions = ReactQueryOptions['post']['byId'];
type PostByIdInput = RouterInputs['post']['byId'];
function usePostById(input: PostByIdInput, options?: PostByIdOptions) {
return trpc.post.byId.useQuery(input, options);
}
Обработка ошибок
При возникновении ошибки в процедуре, tRPC отвечает клиенту объектом, включающим свойство error
. Это свойство содержит всю информацию, необходимую для обработки ошибки на клиенте.
Пример ответа на "плохой запрос" (bad request):
{
"id": null,
"error": {
"message": "\"password\" должен состоять из 4 и более символов",
"code": -32600,
"data": {
"code": "BAD_REQUEST",
"httpStatus": 400,
"stack": "...",
"path": "user.changepassword"
}
}
}
Обратите внимание: трассировка стека (stack
) возвращается только в режиме разработки.
Коды ошибок
tRPC определяет список кодов ошибок, представляющих тип ошибки и код ответа HTTP.
Утилита getHTTPStatusCodeFromError
позволяет извлекать код HTTP из ошибки:
import { getHTTPStatusCodeFromError } from '@trpc/server/http';
// Пример ошибки
const error: TRPCError = {
name: 'TRPCError',
code: 'BAD_REQUEST',
message: '"password" должен состоять из 4 и более символов',
};
if (error instanceof TRPCError) {
const httpCode = getHTTPStatusCodeFromError(error);
console.log(httpCode); // 400
}
Генерация исключений
tRPC предоставляет класс TRPCError
, являющийся подклассом Error
, который может использоваться для представления ошибки, возникшей в процедуре.
Например, генерация такого исключения:
import { TRPCError, initTRPC } from '@trpc/server';
const t = initTRPC.create();
const appRouter = t.router({
hello: t.procedure.query(() => {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Возникла ошибка, попробуйте позже.',
// Опционально: передаем оригинальную ошибку для сохранения трассировки стека
cause: theError,
});
}),
});
Приведет к такому ответу:
{
"id": null,
"error": {
"message": "Возникла ошибка, попробуйте позже.",
"code": -32603,
"data": {
"code": "INTERNAL_SERVER_ERROR",
"httpStatus": 500,
"stack": "...",
"path": "hello"
}
}
}
Обработка ошибок
Все ошибки, возникающие в процедуре, проходят через метод onError
перед передачей клиенту. Это отличное место для обработки ошибок:
export default trpcNext.createNextApiHandler({
// ...
onError({ error, type, path, input, ctx, req }) {
console.error('Ошибка:', error);
if (error.code === 'INTERNAL_SERVER_ERROR') {
// Отправляем отчет об ошибке
}
},
});
Параметр, принимаемый onError()
, представляет собой объект, содержащий всю необходимую информацию об ошибке и контексте ее возникновения:
{
error: TRPCError; // оригинальная ошибка
type: 'query' | 'mutation' | 'subscription' | 'unknown';
path: string | undefined; // путь процедуры
input: unknown;
ctx: Context | undefined;
req: BaseRequest; // объект запроса
}
Форматирование ошибок
Добавление кастомного редактора (на примере zod)
import { initTRPC } from '@trpc/server';
export const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
? error.cause.flatten()
: null,
},
};
},
});
Использование в React
export function MyComponent() {
const mutation = trpc.addPost.useMutation();
useEffect(() => {
mutation.mutate({ title: 'пример' });
}, []);
if (mutation.error?.data?.zodError) {
// Выводится `zodError`
return (
<pre>Ошибка: {JSON.stringify(mutation.error.data.zodError, null, 2)}</pre>
);
}
// ...
}
Параметр, передаваемый errorFormatter()
{
error: TRPCError;
type: ProcedureType | 'unknown';
path: string | undefined;
input: unknown;
ctx: undefined | TContext;
shape: DefaultErrorShape; // дефолтная форма (shape) ошибки
}
DefaultErrorShape
:
interface DefaultErrorData {
code: TRPC_ERROR_CODE_KEY;
httpStatus: number;
path?: string;
stack?: string;
}
interface DefaultErrorShape
extends TRPCErrorShape<TRPC_ERROR_CODE_NUMBER, DefaultErrorData> {
message: string;
code: TRPC_ERROR_CODE_NUMBER;
}
Преобразователи данных / Data transformers
Мы можем преобразовывать как данные, включаемые в ответ, так и входные аргументы. Преобразователи (transformers) должны быть добавлены как на сервере, так и на клиенте.
Использование superjson
SuperJSON позволяет прозрачно передавать, например, стандартные Date/Map/Set
между сервером и клиентом. Это позволяет возвращать указанные типы из резолверов и использовать их на клиенте без необходимости воссоздания этих объектов из JSON.
- Устанавливаем supejson:
yarn add superjson
# или
npm i superjson
- Добавляем его в
initTRPC()
:
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
export const t = initTRPC.create({
transformer: superjson,
});
- Добавляем его в
createTRPCProxyClient()
илиcreateTRPCNext()
:
import { createTRPCProxyClient } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '~/server/routers/_app';
export const client = createTRPCProxyClient<AppRouter>({
transformer: superjson,
// ...
});
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from '~/server/routers/_app';
export const trpc = createTRPCNext<AppRouter>({
config({ ctx }) {
return {
transformer: superjson,
};
},
// ...
});
Разные преобразователи для загрузки и скачивания
Преобразователи могут использоваться индивидуально, например, когда преобразователь используется в одном направлении или для загрузки (upload) и скачивания (download) используются разные преобразователи (например, по причинам производительности). Важно: сочетание преобразователей должно быть одинаковым на сервере и клиенте.
В следующем примере superjson используется для загрузки данных, а devalue - для их скачивания: devalue является более производительным, но его использование на сервере является небезопасным.
- Устанавливаем пакеты:
yarn add superjson devalue
- Определяем преобразователь:
import { uneval } from 'devalue';
import superjson from 'superjson';
export const transformer = {
input: superjson,
output: {
serialize: (object) => uneval(object),
// Этот `eval` выполняется только на клиенте
deserialize: (object) => eval(`(${object})`),
},
};
- Добавляем преобразователь в
initTRPC()
:
import { initTRPC } from '@trpc/server';
import { transformer } from '../../utils/trpc';
export const t = initTRPC.create({
transformer,
});
export const appRouter = t.router({
// ...
});
- Добавляем преобразователь в
createTRPCProxyClient()
:
import { createTRPCProxyClient } from '@trpc/client';
import { transformer } from '../utils/trpc';
export const client = createTRPCProxyClient<AppRouter>({
transformer,
// ...
});
Интерфейс DataTransformer
export interface DataTransformer {
serialize(object: any): any;
deserialize(object: any): any;
}
interface InputDataTransformer extends DataTransformer {
/**
* Запускается на клиенте перед отправкой данных на сервер
*/
serialize(object: any): any;
/**
* Запускается на сервере для преобразования данных перед их передачей резолверу
*/
deserialize(object: any): any;
}
interface OutputDataTransformer extends DataTransformer {
/**
* Запускается на сервере перед отправкой данных клиенту
*/
serialize(object: any): any;
/**
* Запускается на клиенте для преобразования данных, полученных от кл иента
*/
deserialize(object: any): any;
}
export interface CombinedDataTransformer {
/**
* Определяет преобразование данных, передаваемых клиентом серверу (входных данных)
*/
input: InputDataTransformer;
/**
* Определяет преобразования данных, передаваемых сервером клиенту (исходящих данных)
*/
output: OutputDataTransformer;
}
Метаданные
Метод процедуры meta
позволяет добавлять дополнительные данные для посредников.
Создание роутера с типизированными метаданными
import { initTRPC } from '@trpc/server';
interface Meta {
hasAuth: boolean;
}
export const t = initTRPC.context<Context>().meta<Meta>().create();
export const appRouter = t.router({
// ...
});
Пример настроек аутентификации
import { initTRPC } from '@trpc/server';
// ...
interface Meta {
hasAuth: boolean;
}
export const t = initTRPC.context<Context>().meta<Meta>().create();
const isAuthed = t.middleware(async ({ meta, next, ctx }) => {
// Проверка наличия пользователя выполняется только если `hasAuth` имеет значение `true`
if (meta?.hasAuth && !ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx });
});
export const authedProcedure = t.procedure.use(isAuthed);
export const appRouter = t.router({
public: authedProcedure.meta({ hasAuth: false }).query(() => {
return {
greeting: 'привет, незнакомец',
};
}),
protected: authedProcedure.meta({ hasAuth: true }).query(({ ctx }) => {
return {
greeting: `привет, ${ctx.user.name}`,
};
}),
});
Кэширование ответов
В приводимых ниже примерах используется граничное кэширование (edge caching) Vercel для максимально быстрой доставки данных пользователям.
Важно: работа с кэшем предполагает крайнюю внимательность и осторожность, особенно, при обработке персональных данных. Поскольку группировка (объединение) запросов (батчинг, batching) включена по умолчанию, кэш-заголовки HTTP рекомендуется устанавливать в функции responseMeta
. При этом, необходимо убедиться, что отсутствуют конкурентные запросы, которые могут записать в кэш персональные данные. Также рекомендуется выключать кэширование при наличии заголовков авториз ации или куки.
Кэширование приложения
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import type { AppRouter } from '../server/routers/_app';
export const trpc = createTRPCNext<AppRouter>({
config({ ctx }) {
if (typeof window !== 'undefined') {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
const url = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: 'http://localhost:3000/api/trpc';
return {
links: {
http: httpBatchLink({
url,
}),
},
};
},
ssr: true,
responseMeta({ ctx, clientErrors }) {
if (clientErrors.length) {
// Если на клиенте возникла ошибка
return {
status: clientErrors[0].data?.httpStatus ?? 500,
};
}
// Кэшируем ответ на 1 день и ревалидируем кэш каждую секунду
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
return {
headers: {
'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
},
};
},
});
Кэширование ответов
Поскольку все запросы (queries) - это обычные GET-запросы HTTP, для кэширования ответов на них можно использовать соответствующие заголовки HTTP:
import { inferAsyncReturnType, initTRPC } from '@trpc/server';
import * as trpcNext from '@trpc/server/adapters/next';
export const createContext = async ({
req,
res,
}: trpcNext.CreateNextContextOptions) => {
return {
req,
res,
prisma,
};
};
type Context = inferAsyncReturnType<typeof createContext>;
export const t = initTRPC.context<Context>().create();
const waitFor = async (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
export const appRouter = t.router({
public: t.router({
slowQueryCached: t.procedure.query(async ({ ctx }) => {
await waitFor(5000); // ждем 5 секунд
return {
lastUpdated: new Date().toJSON(),
};
}),
}),
});
export type AppRouter = typeof appRouter;
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
responseMeta({ ctx, paths, type, errors }) {
// Предположим, что все открытые роуты содержат префикс `public`
const allPublic = paths && paths.every((path) => path.includes('public'));
// Убеждаемся в отсутствии ошибок
const allOk = errors.length === 0;
// Убеждаемся в том, что выполняется запрос, а не мутация
const isQuery = type === 'query';
if (ctx?.res && allPublic && allOk && isQuery) {
// Кэшируем ответ
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
return {
headers: {
'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
},
};
}
return {};
},
});
@trpc/client
Клиент на чистом TypeScript
Одним из основных преимуществ tRPC является возможность использования серверных типов на клиенте. Для этого достаточно импортировать тип корневого роутера:
import type { AppRouter } from '../path/to/server/trpc';
AppRouter
представляет сигнатуру всего интерфейса.
Инициализация клиента tRPC
Метод createTRPCProxyClient
позволяет создавать типобезопасных клиентов и определять адреса серверов:
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from '../path/to/server/trpc';
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
Примеры использования клиента:
const bilbo = await client.getUser.query('id_bilbo');
// => { id: 'id_bilbo', name: 'Bilbo' };
const frodo = await client.createUser.mutate({ name: 'Frodo' });
// => { id: 'id_frodo', name: 'Frodo' };
Прерывание вызовов процедур
@trpc/react-query
По умолчанию tRPC не отменяет запросы, находящиеся в процессе выполнения, при размонтировании компонентов. Однако для этого достаточно установить настройку abortOnUnmount
в значение true
:
// @filename: utils.ts
import { createTRPCReact } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>();
trpc.createClient({
// ...
abortOnUnmount: true,
});
Это также можно реализовать на уровне запросов:
// @filename: pages/posts/[id].tsx
import { trpc } from '~/utils/trpc';
const PostViewPage: NextPageWithLayout = () => {
const id = useRouter().query.id as string;
const postQuery = trpc.post.byId.useQuery({ id }, { trpc: { abortOnUnmount: true } });
// ...
}
Важно: @tanstack/react-query позволяет отменять только запросы (queries).
@trpc/client
При создании клиента на чистом TS достаточно передать AbortController
в настройках запроса и вызвать метод abort()
родительского AbortController
:
// @filename: server.ts
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from 'server.ts';
const proxy = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
});
const ac = new AbortController();
const query = proxy.userById.query('id_bilbo', { signal: ac.signal });
// Отмена
ac.abort();
console.log(query.status);
Важно: клиент на чистом TS позволяет отменять как запросы, так и мутации (mutations).