tRPC 非 JSON 内容类型实战:以 minimal-content-types 示例解析 FormData 与二进制文件上传的端到端类型安全实现
本篇指南以 examples/minimal-content-types 示例工程为主体,讲解 tRPC 如何处理默认 JSON 之外的内容类型——FormData、File 及其他二进制输入。读完后你能掌握:如何在服务端用 z.instanceof(FormData) 与 octetInputParser 声明非 JSON 输入并在类型系统内消费它们,如何在客户端零额外配置地通过 httpLink 发送 File/FormData,以及如何完整运行、构建和端到端验证这套示例。
示例定位:为什么需要 non-JSON content types
tRPC 默认收发 JSON 可序列化的数据。但文件上传、表单提交等场景天然产生 FormData、File、Blob 这类无法(或不适合)JSON 序列化的输入。官方文档(非 JSON 内容类型)将其列为 tRPC 服务端的一等能力:服务端会根据请求的 Content-Type 头自行解析请求体,把二进制内容转成可在 procedure 内消费的 ReadableStream。
examples/minimal-content-types 就是为这一能力准备的最小可运行 React 示例,README 明确给出了两条基线信息:
- 依赖 Node 18(因为使用了全局
fetch及FormData/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() 取出内容,非文件项(如 name、occupation)原样存入返回对象。源码注释特别标注了这类输入的语义——"should expect the input to be loaded into memory",即 FormData 在 tRPC 解析后是完整驻留内存的,超大表单需要自行评估内存占用。
2. 二进制输入:octetInputParser 与 ReadableStream
octetInputParser 从 @trpc/server/http 导出。从源码看,packages/server/src/http.ts 仅做一行转发:
export * from './@trpc/server/http';
而 packages/server/src/@trpc/server/http.ts 明确导出了 octetInputParser 及其配套类型 OctetInput、FileLike、UtilityParser。tRPC 会把多种 octet 内容类型(Blob、Uint8Array、File 等)统一转换成 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/server、cors、zod,不依赖任何框架;而 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,塞入两个字符串字段和一个File(new 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 内容类型。如果你同时使用 httpBatchLink 或 httpBatchStreamLink(批处理 link 会把多个操作合并成一个 JSON 请求,二进制输入无法参与),需要用 splitLink + isNonJsonSerializable 按内容类型分流:
createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => isNonJsonSerializable(op.input),
true: httpLink({ url }),
false: httpBatchLink({ url }),
}),
],
});
另外两条文档给出的配套规则也值得记住:
- 若服务端配置了
transformer,客户端对应 link 也必须定义transformer(TS 会强制检查); - 服务端框架(如 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 等)而保持同样的输入声明与客户端调用方式不变。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00