首页
/ tRPC 非 JSON 内容类型实战:以 minimal-content-types 示例解析 FormData 与二进制文件上传的端到端类型安全实现

tRPC 非 JSON 内容类型实战:以 minimal-content-types 示例解析 FormData 与二进制文件上传的端到端类型安全实现

2026-09-05 11:26:26作者:田桥桑Industrious

本篇指南以 examples/minimal-content-types 示例工程为主体,讲解 tRPC 如何处理默认 JSON 之外的内容类型——FormDataFile 及其他二进制输入。读完后你能掌握:如何在服务端用 z.instanceof(FormData)octetInputParser 声明非 JSON 输入并在类型系统内消费它们,如何在客户端零额外配置地通过 httpLink 发送 File/FormData,以及如何完整运行、构建和端到端验证这套示例。

示例定位:为什么需要 non-JSON content types

tRPC 默认收发 JSON 可序列化的数据。但文件上传、表单提交等场景天然产生 FormDataFileBlob 这类无法(或不适合)JSON 序列化的输入。官方文档(非 JSON 内容类型)将其列为 tRPC 服务端的一等能力:服务端会根据请求的 Content-Type 头自行解析请求体,把二进制内容转成可在 procedure 内消费的 ReadableStream

examples/minimal-content-types 就是为这一能力准备的最小可运行 React 示例,README 明确给出了两条基线信息:

  • 依赖 Node 18(因为使用了全局 fetchFormData/File/Blob 等 Web API);
  • 客户端是一个 Vite + React 应用,服务端是独立 HTTP 服务,通过类型 AppRouter 实现端到端类型检查。

项目结构与运行方式

示例采用 npm workspaces 组织两个子包,目录结构如下:

examples/minimal-content-types/
├── client/                 # Vite + React 前端
│   └── src/
│       ├── App.tsx                        # 客户端入口组件,创建 tRPC client
│       ├── SendFileButton.tsx             # 发送 File 的按钮
│       ├── SendMultipartFormDataButton.tsx # 发送 FormData 的按钮
│       └── utils/trpc.ts                  # createTRPCReact<AppRouter>()
├── server/
│   └── index.ts                  # API handler + standalone HTTP 服务器
├── test/smoke.test.ts            # Playwright 冒烟测试
├── playwright.config.ts
└── package.json

package.json 中的脚本把两个子包编排在一起(-w 即 workspaces):

{
  "scripts": {
    "build": "run-s build:server build:client",
    "dev": "run-p dev:*",
    "start": "run-p start:*",
    "test:e2e": "playwright test",
    "test-dev": "start-server-and-test dev 3000 test:e2e"
  }
}

按 README 的指引运行:

# 开发模式(Node 18+)
npm i
npm run dev

# 生产构建
npm run build
npm run start

dev 会并行启动 Vite 客户端(vite.config.ts 中固定 port: 3000)与服务端(监听 2022 端口,见下文)。README 还提示:可以直接编辑 TS 文件,观察端到端类型检查如何即时生效——这正是本示例的教学价值所在。

服务端实现:两种非 JSON 输入的声明与消费

完整服务端代码见 server/index.ts,核心是一个包含两条 mutation 的 router:

import { initTRPC } from '@trpc/server';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import { octetInputParser } from '@trpc/server/http';
import cors from 'cors';
import { z } from 'zod';

const t = initTRPC.create();
const publicProcedure = t.procedure;
const router = t.router;

const appRouter = router({
  // 1) FormData 输入:期望输入已整体加载到内存
  formData: publicProcedure
    .input(z.instanceof(FormData))
    .mutation(async ({ input }) => {
      const object = {} as Record<string, unknown>;
      for (const [key, value] of input.entries()) {
        if (value instanceof File) {
          object[key] = {
            name: value.name,
            type: value.type,
            size: value.size,
            text: await value.text(),
          };
        } else {
          object[key] = value;
        }
      }
      console.log('FormData: ', object);
      return { text: 'ACK', data: object };
    }),

  // 2) File / 二进制输入:octetInputParser 产出 ReadableStream
  file: publicProcedure.input(octetInputParser).mutation(async ({ input }) => {
    const chunks = [];
    const reader = input.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      chunks.push(value);
    }
    const content = Buffer.concat(chunks).toString('utf-8');
    console.log('File: ', content);
    return { text: 'ACK', data: content };
  }),
});

// 只导出类型定义给客户端,不暴露实现
export type AppRouter = typeof appRouter;

// standalone 适配器直接创建 HTTP 服务器
createHTTPServer({
  middleware: cors(),
  router: appRouter,
  createContext() {
    return {};
  },
  onError(opts) {
    console.error('Error', opts.error);
  },
}).listen(2022);

这里有三个关键设计点值得展开:

1. FormData 输入:z.instanceof(FormData) 即校验器

z.instanceof(FormData) 直接把「输入必须是 FormData 实例」写进了 zod schema,因此 opts.input 在 handler 内被推断为 FormData 类型,可以直接调用 entries() 逐项处理。示例对每个条目做了 instanceof File 判别:文件项只读取元信息(name/type/size)并调用 value.text() 取出内容,非文件项(如 nameoccupation)原样存入返回对象。源码注释特别标注了这类输入的语义——"should expect the input to be loaded into memory",即 FormData 在 tRPC 解析后是完整驻留内存的,超大表单需要自行评估内存占用。

2. 二进制输入:octetInputParserReadableStream

octetInputParser@trpc/server/http 导出。从源码看,packages/server/src/http.ts 仅做一行转发:

export * from './@trpc/server/http';

packages/server/src/@trpc/server/http.ts 明确导出了 octetInputParser 及其配套类型 OctetInputFileLikeUtilityParser。tRPC 会把多种 octet 内容类型(BlobUint8ArrayFile 等)统一转换成 ReadableStream,所以 file 这条 mutation 里 input 就是一个标准的 Web 流:通过 input.getReader() 循环 read(),直到 done,再用 Buffer.concat(chunks) 拼装出完整内容。这种流式消费方式意味着服务端不需要一次性把整个文件读进内存,是处理大文件上传的关键前提。

3. standalone 适配器与端口约定

示例没有套 Express/Fastify,而是用 createHTTPServer@trpc/server/adapters/standalone)直接起一个 Node HTTP 服务并 .listen(2022)middleware: cors() 处理跨域(因为 Vite 客户端跑在 3000 端口,属于跨源请求)。onError 钩子统一打印错误——在更大应用中可以在此接日志/告警。

另请注意 server/package.json 所在工作区的职责划分:server 子包只依赖 @trpc/servercorszod,不依赖任何框架;而 client/src/utils/trpc.ts 直接 import type { AppRouter } from '../../../server',跨包引用服务端类型——这就是 README 所说"edit the ts files to see the type checking in action"的机制:类型错误会在客户端包中直接报出。

客户端实现:httpLink 原生支持非 JSON 内容类型

客户端入口在 App.tsx

const [trpcClient] = useState(() =>
  trpc.createClient({
    links: [
      httpLink({
        url: 'http://localhost:2022',
      }),
    ],
  }),
);

return (
  <trpc.Provider client={trpcClient} queryClient={queryClient}>
    <QueryClientProvider client={queryClient}>
      <SendMultipartFormDataButton />
      <SendFileButton />
    </QueryClientProvider>
  </trpc.Provider>
);

要点是:httpLink 开箱即支持非 JSON 内容类型。按官方文档的说明,如果你的客户端只用 httpLink,现有配置无需任何改动即可发送 File/FormData。本示例的 links 配置正是最简形态——只有一个 url,没有 transformer、没有 batch 逻辑。

两个按钮组件演示了两种 mutation 调用方式:

  • SendMultipartFormDataButton.tsx:在 onClick 中构造 FormData,塞入两个字符串字段和一个 Filenew File(['hi bob'], 'bob.txt', { type: 'text/plain' })),然后 mutation.mutate(fd) 一把发出;
  • SendFileButton.tsx<input type="file"> 选择后,直接 mutation.mutate(file) 把原始 File 作为输入。
const mutation = trpc.file.useMutation();
// ...
const file = e.target.files.item(0)!;
mutation.mutate(file);

链接选型提醒:批处理 link 需要 splitLink

虽然本示例只用了 httpLink,但结合官方文档 www/docs/server/non-json-content-types.md 可以得出一个重要的工程约束:并非所有 link 都支持非 JSON 内容类型。如果你同时使用 httpBatchLinkhttpBatchStreamLink(批处理 link 会把多个操作合并成一个 JSON 请求,二进制输入无法参与),需要用 splitLink + isNonJsonSerializable 按内容类型分流:

createTRPCClient<AppRouter>({
  links: [
    splitLink({
      condition: (op) => isNonJsonSerializable(op.input),
      true: httpLink({ url }),
      false: httpBatchLink({ url }),
    }),
  ],
});

另外两条文档给出的配套规则也值得记住:

  1. 若服务端配置了 transformer,客户端对应 link 也必须定义 transformer(TS 会强制检查);
  2. 服务端框架(如 Express)不要在 tRPC 接管路由之前解析请求体,否则会触发 Failed to parse body as XXX 错误——示例用 standalone 适配器完全绕开了这个问题,这也是它作为"minimal"示例的又一价值。

端到端验证

示例自带 Playwright 冒烟测试 test/smoke.test.ts

test('go to /', async ({ page }) => {
  await page.goto('/');
  await page.waitForSelector(`text=tRPC user`);
});

配合 package.json 中的 test-dev / test-start 脚本(start-server-and-test dev 3000 test:e2e),先等 3000 端口(Vite 客户端)就绪后再跑 E2E,覆盖开发模式与生产构建两条路径。手动验证时,开发模式下访问 http://localhost:3000 即可看到 "Send FormData" 与 "Send File" 两个控件,点击后服务端控制台会打印 FormData: ... / File: ...,客户端 mutation 返回 { text: 'ACK', data: ... }

小结

examples/minimal-content-types 用不到 200 行代码覆盖了 tRPC 非 JSON 内容类型全链路:

能力 服务端写法 客户端写法 源码依据
FormData 输入 .input(z.instanceof(FormData)) mutation.mutate(formData) server/index.ts
File / 二进制输入 .input(octetInputParser),以 ReadableStream 流式消费 mutation.mutate(file) server/index.ts
类型贯通 export type AppRouter = typeof appRouter createTRPCReact<AppRouter>() client/src/utils/trpc.ts
跨源请求 standalone 适配器 + cors() 中间件 httpLink 指向 http://localhost:2022 client/src/App.tsx

需要牢记的适用前提:Node 18+(全局 fetch 与 Web 文件 API)、FormData 输入整体驻留内存、二进制输入以流式消费、批处理 link 需 splitLink 分流。以此为骨架,你可以在自己的项目里替换 standalone 适配器(Express/Fastify/Next.js 等)而保持同样的输入声明与客户端调用方式不变。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
983
503
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384