tRPC 非 JSON 内容类型(Content Types)完整指南:FormData、File、Blob 与二进制输入
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
tRPC 不仅能传输 JSON 数据,还内置支持以FormData、File、Blob、Uint8Array等非 JSON 类型作为 procedure 输入,为文件上传、表单提交、二进制数据流等场景提供端到端类型安全的能力。本文以官方文档 www/docs/server/non-json-content-types.md 为主体,结合仓库中客户端与服务端源码实现,完整讲解非 JSON 输入的客户端 link 配置、服务端 body 解析原理、octetInputParser与类型推断,以及各类需要绕开的坑,读完即可在你的 tRPC 应用中落地真实可用的文件上传 procedure。
一、tRPC 支持哪些输入内容类型
tRPC 的服务端请求解析与客户端发送逻辑都以 HTTP 请求的Content-Type为分派依据。按官方文档,tRPC 可接受以下类型的 procedure 输入:
| 内容类型 | Content-Type | 典型输入 | 是否需要额外配置 |
|---|---|---|---|
| JSON(默认) | application/json | 任何 JSON 可序列化数据 | 否,开箱即用 |
| FormData | multipart/form-data | 表单(含文件字段) | 客户端视 link 而定,服务端原生支持 |
| octet 二进制 | application/octet-stream | Blob、Uint8Array、File | 同上 |
从源码结构看,服务端在 packages/server/src/unstable-core-do-not-import/http/contentTypeParsers.ts 中维护了一组ContentTypeHandler,分别对应三种内容类型,并以req.headers.get('content-type')?.startsWith(...)判断是否匹配:
jsonContentTypeHandler:匹配application/json;formDataContentTypeHandler:匹配multipart/form-data;octetStreamContentTypeHandler:匹配application/octet-stream。
因此,tRPC 服务端是"照着Content-Type头干活"的:请求体如何解析完全由该 header 决定。
二、默认的 JSON 输入
默认情况下,tRPC 收发的是 JSON 可序列化数据,无需任何额外配置。只要是能被序列化为 JSON 的输入,都可以配合所有 link(httpLink、httpBatchLink、httpBatchStreamLink)正常工作:
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure.input(z.object({ name: z.string() })).query((opts) => { return { greeting: `Hello ${opts.input.name}` }; }), });这段代码中helloprocedure 接收一个{ name: string }的 JSON 对象。它对应jsonContentTypeHandler的解析逻辑:非批量调用时读取 body 中的 JSON,再经由 router 配置的 transformer 对 input 做反序列化(见 contentTypeParsers.ts)。
三、非 JSON 内容类型:客户端配置
虽然 tRPC 原生支持若干非 JSON 序列化类型,但根据客户端所用 link 的不同,可能需要一点点 link 配置。官方文档的结论非常明确:
httpLink开箱即用,天然支持非 JSON 内容类型,若你只使用这一个 link,现有配置即可立即工作;httpBatchLink/httpBatchStreamLink不支持这些类型,需要借助splitLink按内容类型分流请求。
3.1 使用httpLink直连
仅使用httpLink时,客户端无需任何特殊处理:
import { createTRPCClient, httpLink } from '@trpc/client'; import type { AppRouter } from './server'; createTRPCClient<AppRouter>({ links: [ httpLink({ url: 'http://localhost:2022', }), ], });为什么httpLink能直接支持?因为 packages/client/src/links/httpLink.ts 的底层universalRequester会对输入做运行时判别:
- 若输入是
FormData:走httpRequest,contentTypeHeader置为undefined,让浏览器自动带上multipart/form-data; boundary=...,body 直接使用该FormData; - 若输入是 octet 类型(
Blob/Uint8Array/File):contentTypeHeader强制设为application/octet-stream,body 直接使用原始输入对象; - 否则回退到 JSON 请求器(
jsonHttpRequester),以application/json发送 JSON.stringify 后的 body。
注意,客户端对这两类输入做了仅限 mutation(POST)的限制:若 procedure 类型不是mutation且未设置methodOverride: 'POST',会直接抛出错误FormData is only supported for mutations/Octet type input is only supported for mutations。这正是文档中所有非 JSON 示例都使用.mutation(...)的原因。
3.2 批量 link 场景:用splitLink分流
httpBatchLink与httpBatchStreamLink的原理是把多个操作打包成一个 JSON 数组请求,天然无法承载FormData/Blob这类二进制 body。因此需要splitLink做条件分流:非 JSON 可序列化的输入走httpLink,其余走批量 link。
splitLink的语义是:根据condition(op)的真假选择true/false两个分支中的 link 链(见 packages/client/src/links/splitLink.ts)。结合@trpc/client导出的类型守卫isNonJsonSerializable,即可优雅分流:
import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink, } from '@trpc/client'; import type { AppRouter } from './server'; const url = 'http://localhost:2022'; createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (op) => isNonJsonSerializable(op.input), true: httpLink({ url, }), false: httpBatchLink({ url, }), }), ], });这里isNonJsonSerializable由 packages/client/src/links/internals/contentTypes.ts 实现,其定义是:
export function isOctetType(input: unknown): input is Uint8Array | Blob { return input instanceof Uint8Array || input instanceof Blob; } export function isFormData(input: unknown) { return input instanceof FormData; } export function isNonJsonSerializable(input: unknown) { return isOctetType(input) || isFormData(input); }File在浏览器/Node.js 高版本中继承自Blob,因此会被isOctetType一并命中;这些类型守卫同时通过 packages/client/src/links/types.ts 与@trpc/client的入口 packages/client/src/index.ts 公开导出。
3.3 使用 transformer(如 superjson)时的客户端配置
如果服务端 tRPC 配置了transformer(例如superjson),TypeScript 会强制要求客户端 link 同样声明transformer。此时需要为两个分支分别配置,官方文档给出的基座示例如下:
import { createTRPCClient, httpBatchLink, httpLink, isNonJsonSerializable, splitLink, } from '@trpc/client'; import superjson from 'superjson'; import type { AppRouter } from './server'; const url = 'http://localhost:2022'; createTRPCClient<AppRouter>({ links: [ splitLink({ condition: (op) => isNonJsonSerializable(op.input), true: httpLink({ url, transformer: { // request - convert data before sending to the tRPC server serialize: (data) => data, // response - convert the tRPC response before using it in client deserialize: (data) => superjson.deserialize(data), // or your other transformer }, }), false: httpBatchLink({ url, transformer: superjson, // or your other transformer }), }), ], });这里有一处非常关键的实现细节:走httpLink的非 JSON 请求,其请求体是原始二进制/FormData,不能经过 superjson 序列化,因此serialize被设为恒等函数(data) => data;而响应仍是 JSON,需要superjson.deserialize还原。走httpBatchLink的 JSON 分支则直接整链使用 superjson。响应解析发生在 packages/client/src/links/httpLink.ts 的transformResult(res.json, resolvedOpts.transformer.output)中,与请求方向的序列化策略是分开的。
四、非 JSON 内容类型:服务端配置
当请求到达 tRPC 时,tRPC 会依据请求的Content-Type头自行解析请求体,无需你手动JSON.parse或读流。整套分派流程的入口是 contentTypeParsers.ts 中的getContentTypeHandler→getRequestInfo:
- 遍历
handlers(json / formData / octetStream)并执行isMatch; - 若无匹配且方法是
GET,回退到 JSON handler(便于浏览器直接打开 GET 请求); - 若仍无匹配,抛出
UNSUPPORTED_MEDIA_TYPE的TRPCError(消息为Unsupported content-type "..."或Missing content-type header)。
同时,两个非 JSON handler(FormData、octet-stream)都强制要求请求方法是POST,否则抛出METHOD_NOT_SUPPORTED,这再一次印证了"非 JSON 输入 = mutation"的约束。
4.1 重要:不要让上游框架提前消费 body
:::info tRPC 会根据Content-Type头自行解析请求体。如果你遇到类似Failed to parse body as XXX的错误,请确认你的服务端(例如 Express、Next.js)没有在 tRPC 处理之前提前解析请求体。 :::
官方文档以 Express 为例给出了错误与正确两种写法:
// Example in express import express from 'express'; import * as trpcExpress from '@trpc/server/adapters/express'; import { appRouter } from './router'; // incorrect const app1 = express(); app1.use(express.json()); // this tries to parse body before tRPC. app1.post('/express/hello', (req, res) => { res.end(); }); // normal express route handler app1.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter })); // tRPC fails to parse body // correct const app2 = express(); app2.use('/express', express.json()); // do it only in "/express/*" path app2.post('/express/hello', (req, res) => { res.end(); }); app2.use('/trpc', trpcExpress.createExpressMiddleware({ router: appRouter })); // tRPC can parse body区别在于:全局app.use(express.json())会把所有请求体先按 JSON 解析一遍,破坏原始 body;而将express.json()限定在'/express'前缀路径内,/trpc下的请求体则完整保留给 tRPC 自行解析。这一原则对 Next.js API Routes / App Router、Fastify、Koa 等所有适配器同样适用——凡在 tRPC 之前发生 body 读取/解析的中间件,都可能破坏非 JSON 输入。仓库各适配器文档可参见 www/docs/server/adapters 目录。
4.2FormData输入
FormData在服务端被原生支持。最简单的做法是用z.instanceof(FormData)声明输入:
import { initTRPC } from '@trpc/server'; import { z } from 'zod'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ hello: publicProcedure.input(z.instanceof(FormData)).mutation((opts) => { const data = opts.input; return { greeting: `Hello ${data.get('name')}`, }; }), });在上面的 procedure 里,opts.input就是一个真正的FormData实例,可以直接用.get('name')、.get('file')等方法读取字段与文件。
对于更高级的用法,可配合zod-form-data库对 FormData 做类型安全校验。仓库中的 examples/next-formdata 就是官方提供的 Next.js + tRPC + FormData 完整示例,其 examples/next-formdata/src/utils/schemas.ts 使用zfd.formData(...)构建 schema:
import { zfd } from 'zod-form-data'; export const uploadFileSchema = zfd.formData({ // 字段级校验定义,例如文件、文本字段等 });schema 同时被路由端与 react-hook-form 页面 复用,客户端直接mutation.mutateAsync(new FormData(event.currentTarget))即可提交包含文件的表单。
说明:
zod-form-data并非本仓库的依赖,仅作为推荐搭配出现;完整可运行范本请参照 examples/next-formdata 的源码与 README。
服务端解析FormData的底层逻辑由 formDataContentTypeHandler 完成:匹配multipart/form-data后调用req.formData()读取表单并作为原始输入(getRawInput)暴露给 procedure,type固定为mutation。
4.3File与其它二进制类型输入
对于Blob、Uint8Array、File等 octet 二进制内容,tRPC 会把它们转换为ReadableStream供 procedure 消费。官方文档的标准写法如下:
import { initTRPC } from '@trpc/server'; import { octetInputParser } from '@trpc/server/http'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ upload: publicProcedure.input(octetInputParser).mutation((opts) => { const data = opts.input; // ReadableStream return { valid: true, }; }), });关键点逐一拆解:
octetInputParser从@trpc/server/http导入,它由 packages/server/src/@trpc/server/http.ts 从unstable-core-do-not-import重新导出;- parser 定义位于 contentTypeParsers.ts:类型层面输入是
OctetInput = Blob | Uint8Array | FileLike,输出是ReadableStream;FileLike内部用interface FileLike extends Blob表达File(注释说明File自 Node.js 19+ 才可用,但它始终继承Blob); - 类型推断:由于 parser 的输出类型为
ReadableStream,opts.input在编辑器里会被推导为ReadableStream,可直接调用.getReader()按 chunk 读取; - 服务端 octet 解析由 octetStreamContentTypeHandler 实现:匹配
application/octet-stream、限定 POST 后,直接把req.body(即 ReadableStream)作为原始输入传给 procedure。
一个从服务端读出完整文件内容的真实例程来自 packages/react-query/test/octetStreams.test.tsx:
const appRouter = t.router({ uploadFile: t.procedure .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'); return { fileContent: content, }; }), });这份测试还验证了File、Blob、Uint8Array三种输入都能端到端上传成功(octetStreams.test.tsx):分别用new File(['hi bob'], 'bob.txt', ...)、new Blob(['hi bob'])、new Uint8Array(...)调用client.uploadFile.mutate(...),断言服务端读出的fileContent均为'hi bob'。注意测试文件头部的注释@vitest-environment node,说明二进制/流场景需要在 Node 环境(而非 jsdom)下运行。
五、把文件上传整合进真实应用
综合以上客户端与服务端配置,一个"文件上传到 tRPC"的最小闭环如下:
服务端(router.ts)——用octetInputParser接收二进制并流式读取:
import { initTRPC } from '@trpc/server'; import { octetInputParser } from '@trpc/server/http'; export const t = initTRPC.create(); const publicProcedure = t.procedure; export const appRouter = t.router({ upload: 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); } return { size: chunks.reduce((n, c) => n + c.length, 0) }; }), }); export type AppRouter = typeof appRouter;客户端(client.ts)——若服务端没配 transformer,可按 3.2 的分流配置;上传时直接传File/Blob/Uint8Array:
import { createTRPCClient, httpLink } from '@trpc/client'; import type { AppRouter } from './server'; const client = createTRPCClient<AppRouter>({ links: [httpLink({ url: 'http://localhost:2022' })], }); // 例如从 <input type="file"> 拿到 file const res = await client.upload.mutate(file);若服务端启用了superjson等 transformer,请把 3.3 节的 splitLink 基座搬过来即可。仓库中可对照参考的完整范本包括:
- examples/next-formdata:Next.js + tRPC 处理
multipart/form-data表单文件上传; - examples/minimal-content-types:极简 React + tRPC 内容类型演示(Node 18+ 全局 fetch);
- packages/react-query/test/octetStreams.test.tsx:
File/Blob/Uint8Array上传的端到端测试,含 splitLink + 批量分流写法。
六、实践要点与常见坑位汇总
- 非 JSON 输入只能是 mutation。客户端
httpLink与服务端contentTypeParsers都强制 FormData / octet 输入走 POST mutation;若误用于 query 会得到FormData is only supported for mutations/Octet type input is only supported for mutations(客户端)或METHOD_NOT_SUPPORTED(服务端)。 - 使用批量 link 时务必用
splitLink分流,否则FormData/Blob会被当作 JSON 处理而损坏。分流的"标尺"是@trpc/client导出的isNonJsonSerializable(内部为isOctetType(input) || isFormData(input))。更系统的splitLink语义与链路构建说明见 www/docs/client/links/splitLink.mdx,其"按条件禁用批量"正是此场景的典型应用。 - 配置了 transformer 就两侧都要配。服务端
initTRPC.create({ transformer })后,客户端所有 link 均需声明 transformer;非 JSON 分支的serialize应保持恒等(原样发送二进制),只对响应做deserialize。 - 不要在 tRPC 之前让中间件消费 body。Express 全局
express.json()、Next.js 某些 body 解析逻辑等都会导致 tRPC 拿到被破坏的请求体而报Failed to parse body as ...。把 JSON/URL-encoded 中间件限定到非 tRPC 路由前缀即可。 File的运行时可用性有环境前提。客户端isOctetType通过instanceof Blob覆盖File(Node.js 20+ / 浏览器),服务端用FileLike extends Blob表达类型(Node.js 19+),代码注释对此做了明确说明——在低版本 Node 环境中使用前需确认全局Blob/File是否可用。- 服务端按
Content-Type分派,且 GET 请求即使不带 JSON content-type 也会回退到 JSON 解析,方便浏览器直接访问查询类 procedure;其余不支持的类型会得到UNSUPPORTED_MEDIA_TYPE错误。
通过上述配置,你可以在保持 tRPC 全程类型安全的前提下,把 JSON、表单与任意二进制流统一收进同一套 router 与类型推导体系中——这正是 tRPC 文档所定义的"Move Fast and Break Nothing"式文件上传体验。
【免费下载链接】trpc🧙♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考