Другое

tRPC: исправление чтения тела в Hono на Cloudflare Workers

Узнайте, как решить проблемы чтения тела запроса tRPC при использовании Hono в Cloudflare Workers. Руководство с исправлениями middleware и примерами.

tRPC Body Не Читается Корректно с Hono в Cloudflare Workers

У меня возникла проблема: tRPC не читает тело запроса корректно при использовании Hono в Cloudflare Workers. Несмотря на то, что тело присутствует в запросе, процедуры tRPC получают undefined в качестве входных данных.

Описание проблемы

Я пытаюсь интегрировать Cloudflare Workers, Hono и tRPC уже несколько часов. Я использую пакет @hono/trpc-server, который предоставляет middleware для запуска tRPC в Hono. Хотя я знаю, что такая интеграция возможна (есть пример с Cloudflare Workers и базой D1), я сталкиваюсь с тем, что tRPC вообще не читает тело запроса.

Пример процедуры

Ниже пример процедуры, которая не работает:

typescript
create: publicProcedure
  .input(z.object({ title: z.string() }))
  .mutation(async ({ ctx, input }) => {
    console.error(input);
    const quest = await ctx.db
      .insertInto("quest")
      .values(input)
      .returningAll()
      .executeTakeFirst();
    return quest;
  }),

Валидация падает до того, как процедура даже достигает мутации, и в формате ошибки видно, что входные данные undefined, когда запросы отправляются через Postman, хотя заголовок определённо присутствует в запросе.

Конфигурация сервера

Вот как я обслуживаю приложение:

typescript
const app = new Hono<{ Bindings: Bindings }>();

app.use(
  "/trpc/*",
  trpcServer({
    router: appRouter,
    createContext: (_, c) => createContext(c),
  }),
);

app.use(
  "/*",
  cors({
    origin: "http://localhost:5173",
    credentials: true,
  }),
);

export default app;

Настройка контекста

Вот моя конфигурация контекста:

typescript
import { createDb } from "./db";
import type { Bindings } from "./types";
import type { Context as HonoContext } from "hono";

export const createContext = (c: HonoContext<{ Bindings: Bindings }>) => ({
  db: createDb(c.env.questboard),
});

export type Context = Awaited<ReturnType<typeof createContext>>;

Попытка отладки

Я попытался вывести тело запроса, чтобы убедиться, что оно присутствует:

typescript
app.use("/trpc/*", async (c, next) => {
  console.log("Method:", c.req.method);
  console.log("URL:", c.req.url);
  console.log("Headers:", Object.fromEntries(c.req.raw.headers));

  // Попытка прочитать тело
  const clonedReq = c.req.raw.clone();
  try {
    const body = await clonedReq.text();
    console.log("Raw body:", body);
  } catch (e) {
    console.log("Could not read body:", e);
  }

  return next();
});

Вывод выглядит так:

Method: POST
URL: http://localhost:8787/trpc/quest.create
Headers: {
  accept: '*/*',
  'accept-encoding': 'br, gzip',
  'content-length': '36',
  'content-type': 'application/json',
  host: 'localhost:8787',
  'postman-token': '5e10aea3-3ed3-4abd-8e2c-6158db447a21',
  'user-agent': 'PostmanRuntime/7.49.1'
}
Raw body: {
    "title": "Slay the Dragon"
}

Тело явно присутствует в запросе, но процедуры tRPC его не распознают.

Вопрос

Как можно настроить tRPC так, чтобы он корректно считывал и обрабатывал тело запроса при использовании Hono с Cloudflare Workers? Контекст и запросы к базе данных работают как надо, но входные данные не распознаются процедурами tRPC.

Проблема, с которой вы столкнулись, когда tRPC не читает тело запроса должным образом в Hono с Cloudflare Workers, является известной проблемой совместимости. Корень проблемы кроется в том, как чтение тела запроса обрабатывается между системой промежуточного ПО Hono и ожиданиями tRPC в среде Cloudflare Workers.

Согласно документации пакета Hono tRPC‑server, хотя эта интеграция предназначена для работы с Cloudflare Workers, могут возникать специфические проблемы с чтением тела запроса. В ваших отладочных данных видно, что сырое тело присутствует, но процедуры tRPC получают undefined в качестве входных данных, что указывает на проблему с обработкой промежуточного ПО.


Содержание

Понимание проблемы чтения тела запроса

Проблема возникает из‑за того, что Cloudflare Workers имеют специфические ограничения при работе с запросами. Как отмечено в документации Hono для Cloudflare Workers, среда выполнения значительно отличается от Node.js, особенно в том, как читаются и кэшируются тела запросов.

В GitHub issue #1387 описана известная проблема, при которой тела запросов могут стать недоступными после определённых операций промежуточного ПО. Конкретно в вашем случае tRPC ожидает чтение тела запроса, но к моменту выполнения ваших процедур поток тела уже был потреблен или находится в несовместимом состоянии.

Анализ причины

Ваше отладочное промежуточное ПО корректно показывает, что сырое тело присутствует, но процедуры tRPC получают undefined. Это указывает на:

  1. Потребление потока запроса: Поток тела запроса может быть прочитан промежуточным ПО Hono до того, как tRPC сможет получить к нему доступ.
  2. Особенности Cloudflare Workers: Среда выполнения Cloudflare обрабатывает потоки запросов иначе, чем Node.js.
  3. Совместимость промежуточного ПО tRPC: Мидлвар @hono/trpc-server может не корректно обрабатывать чтение тела запроса в этой среде.

Как показано в обсуждении на Stack Overflow, это распространённый паттерн, когда сырое тело читается, но обработанный ввод недоступен для процедур tRPC.

Решения и обходные пути

Решение 1: Корректное обработка тела запроса в промежуточном ПО

Измените ваше промежуточное ПО, чтобы гарантировать, что тело запроса будет корректно обработано до того, как оно достигнет tRPC:

typescript
app.use("/trpc/*", async (c, next) => {
  // Обрабатываем только POST/PUT запросы с JSON
  if (c.req.method === "POST" || c.req.method === "PUT") {
    const contentType = c.req.header("content-type");
    if (contentType?.includes("application/json")) {
      try {
        // Читаем и парсим тело заранее
        const body = await c.req.json();
        
        // Сохраняем распарсенное тело в контекст
        c.set("parsedBody", body);
        
        // Создаём новый запрос с распарсенным телом
        const newRequest = new Request(c.req.url, {
          method: c.req.method,
          headers: c.req.headers,
          body: JSON.stringify(body)
        });
        
        // Заменяем запрос
        c.req = new Request(c.req.url, {
          method: c.req.method,
          headers: c.req.headers,
          body: JSON.stringify(body)
        });
      } catch (e) {
        console.error("Error parsing request body:", e);
      }
    }
  }
  await next();
});

Решение 2: Конфигурация промежуточного ПО tRPC

Обновите конфигурацию сервера tRPC, чтобы использовать пользовательский адаптер, который корректно обрабатывает чтение тела запроса:

typescript
import { createTRPCS } from "@trpc/server";
import { createTRPCContext } from "./context";

const trpc = createTRPCS();

app.use(
  "/trpc/*",
  trpcServer({
    router: appRouter,
    createContext: (opts) => {
      const { c } = opts;
      return createTRPCContext(c);
    },
    responseMeta: () => {
      return {
        headers: {
          "Content-Type": "application/json",
        },
      };
    },
  })
);

Исправления конфигурации промежуточного ПО

Улучшенное промежуточное ПО для чтения тела

Создайте отдельное промежуточное ПО, которое будет обрабатывать чтение тела запроса:

typescript
const bodyReadingMiddleware = async (c: any, next: any) => {
  // Клонируем запрос, чтобы прочитать тело без его потребления
  const clonedReq = c.req.raw.clone();
  
  // Читаем тело только для соответствующих запросов
  if (clonedReq.method !== "GET" && clonedReq.method !== "HEAD") {
    try {
      const contentType = clonedReq.headers.get("content-type");
      
      if (contentType?.includes("application/json")) {
        const body = await clonedReq.text();
        
        // Сохраняем сырое тело в контекст для tRPC
        c.set("rawBody", body);
        
        // Парсим и сохраняем распарсенное тело
        const parsedBody = JSON.parse(body);
        c.set("parsedBody", parsedBody);
      }
    } catch (error) {
      console.error("Error reading request body:", error);
    }
  }
  
  await next();
};

// Применяем промежуточное ПО до tRPC
app.use("/trpc/*", bodyReadingMiddleware);

Пользовательский обработчик tRPC

Создайте пользовательский обработчик tRPC, который будет работать с улучшенным промежуточным ПО:

typescript
const customTRPCHandler = trpcServer({
  router: appRouter,
  createContext: async ({ c }) => {
    // Получаем распарсенное тело из нашего промежуточного ПО
    const parsedBody = c.get("parsedBody");
    
    return {
      ...createContext(c),
      rawInput: parsedBody, // Передаём сырые данные в контекст
    };
  },
});

app.use("/trpc/*", customTRPCHandler);

Полный рабочий пример

Ниже приведена полная рабочая конфигурация, которая решает проблему чтения тела запроса:

typescript
import { Hono } from "hono";
import { trpcServer } from "@hono/trpc-server";
import { appRouter } from "./router";
import { createContext } from "./context";
import { cors } from "hono/cors";

const app = new Hono<{ Bindings: Bindings }>();

// Промежуточное ПО для обработки чтения тела запроса
app.use("/trpc/*", async (c, next) => {
  if (c.req.method !== "GET" && c.req.method !== "HEAD") {
    const clonedReq = c.req.raw.clone();
    try {
      const contentType = clonedReq.headers.get("content-type");
      if (contentType?.includes("application/json")) {
        const body = await clonedReq.text();
        const parsedBody = JSON.parse(body);
        c.set("parsedBody", parsedBody);
      }
    } catch (error) {
      console.error("Error reading request body:", error);
    }
  }
  await next();
});

// Пользовательский обработчик tRPC, использующий распарсенное тело
app.use(
  "/trpc/*",
  trpcServer({
    router: appRouter,
    createContext: (opts) => {
      const { c } = opts;
      const parsedBody = c.get("parsedBody");
      
      return {
        ...createContext(c),
        // Передаём распарсенное тело в контекст для процедур
        input: parsedBody,
      };
    },
  })
);

// CORS‑промежуточное ПО
app.use(
  "/*",
  cors({
    origin: "http://localhost:5173",
    credentials: true,
  })
);

export default app;

Лучшие практики

  1. Обработка тела запроса: Всегда обрабатывайте чтение тела запроса в промежуточном ПО до того, как оно достигнет tRPC.
  2. Обработка ошибок: Реализуйте надёжную обработку ошибок при парсинге JSON.
  3. Проверка Content‑Type: Проверяйте заголовок content-type перед попыткой парсинга JSON.
  4. Особенности Cloudflare Workers: Помните, что Cloudflare Workers имеют другие характеристики обработки запросов.
  5. Тестирование: Тестируйте как с валидными, так и с невалидными телами запросов, чтобы убедиться в надёжности.

Альтернативные подходы

Если вышеуказанные решения не работают, рассмотрите следующие альтернативы:

Использование встроенного адаптера tRPC

typescript
import { createTRPC } from "@trpc/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";

const trpc = createTRPCS();

app.use(
  "/trpc/*",
  fetchRequestHandler({
    endpoint: "/trpc",
    req: c.req.raw,
    router: appRouter,
    createContext: (opts) => {
      return createContext({ c: opts });
    },
  })
);

Пользовательский адаптер запроса

Создайте пользовательский адаптер, который корректно обрабатывает потоки запросов Cloudflare Workers:

typescript
const createTRPCAdapter = (router: any) => {
  return async (c: any, next: any) => {
    const url = new URL(c.req.url);
    const path = url.pathname.replace("/trpc", "");
    
    if (path.startsWith("/")) {
      try {
        const body = await c.req.json();
        
        const result = await router._def.procedures[path.split(".")[0]]?._def.procedures[path.split(".")[1]]?.({
          ctx: createContext(c),
          input: body,
          type: "mutation",
        });
        
        return c.json(result);
      } catch (error) {
        return c.json({ error: "Invalid request" }, 400);
      }
    }
    
    await next();
  };
};

app.use("/trpc/*", createTRPCAdapter(appRouter));

Ключ к решению этой проблемы — убедиться, что тело запроса корректно читается и становится доступным для процедур tRPC до их выполнения. Среда Cloudflare Workers требует особого внимания к обработке потоков запросов, а приведённые выше решения предлагают различные подходы, чтобы обеспечить совместимость между Hono, tRPC и Cloudflare Workers.

Источники

  1. Документация Hono tRPC Server Middleware
  2. Hono Cloudflare Workers Getting Started
  3. Stack Overflow: Hono tRPC-server body issue
  4. GitHub Issue: Unable to read raw request body after hono 3.5.0
  5. tRPC Discord Discussion: Hono tRPC Server body issue
Авторы
Проверено модерацией
Модерация