完整指南:FormData、File、Blob 与二进制输入)
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/trpctRPC 不仅能传输 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 可序列化数据否开箱即用FormDatamultipart/form-data表单含文件字段客户端视 link 而定服务端原生支持octet 二进制application/octet-streamBlob、Uint8Array、File同上从源码结构看服务端在 packages/server/src/unstable-core-do-not-import/http/contentTypeParsers.ts 中维护了一组ContentTypeHandler分别对应三种内容类型并以req.headers.get(content-type)?.startsWith(...)判断是否匹配jsonContentTypeHandler匹配application/jsonformDataContentTypeHandler匹配multipart/form-dataoctetStreamContentTypeHandler匹配application/octet-stream。因此tRPC 服务端是照着Content-Type头干活的请求体如何解析完全由该 header 决定。二、默认的 JSON 输入默认情况下tRPC 收发的是 JSON 可序列化数据无需任何额外配置。只要是能被序列化为 JSON 的输入都可以配合所有 linkhttpLink、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; createTRPCClientAppRouter({ links: [ httpLink({ url: http://localhost:2022, }), ], });为什么httpLink能直接支持因为 packages/client/src/links/httpLink.ts 的底层universalRequester会对输入做运行时判别若输入是FormData走httpRequestcontentTypeHeader置为undefined让浏览器自动带上multipart/form-data; boundary...body 直接使用该FormData若输入是 octet 类型Blob/Uint8Array/FilecontentTypeHeader强制设为application/octet-streambody 直接使用原始输入对象否则回退到 JSON 请求器jsonHttpRequester以application/json发送 JSON.stringify 后的 body。注意客户端对这两类输入做了仅限 mutationPOST的限制若 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; createTRPCClientAppRouter({ 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例如superjsonTypeScript 会强制要求客户端 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; createTRPCClientAppRouter({ 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遍历handlersjson / formData / octetStream并执行isMatch若无匹配且方法是GET回退到 JSON handler便于浏览器直接打开 GET 请求若仍无匹配抛出UNSUPPORTED_MEDIA_TYPE的TRPCError消息为Unsupported content-type ...或Missing content-type header。同时两个非 JSON handlerFormData、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(...)构建 schemaimport { 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暴露给 proceduretype固定为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输出是ReadableStreamFileLike内部用interface FileLike extends Blob表达File注释说明File自 Node.js 19 才可用但它始终继承Blob类型推断由于 parser 的输出类型为ReadableStreamopts.input在编辑器里会被推导为ReadableStream可直接调用.getReader()按 chunk 读取服务端 octet 解析由 octetStreamContentTypeHandler 实现匹配application/octet-stream、限定 POST 后直接把req.body即 ReadableStream作为原始输入传给 procedure。一个从服务端读出完整文件内容的真实例程来自 packages/react-query/test/octetStreams.test.tsxconst 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/Uint8Arrayimport { createTRPCClient, httpLink } from trpc/client; import type { AppRouter } from ./server; const client createTRPCClientAppRouter({ links: [httpLink({ url: http://localhost:2022 })], }); // 例如从 input typefile 拿到 file const res await client.upload.mutate(file);若服务端启用了superjson等 transformer请把 3.3 节的 splitLink 基座搬过来即可。仓库中可对照参考的完整范本包括examples/next-formdataNext.js tRPC 处理multipart/form-data表单文件上传examples/minimal-content-types极简 React tRPC 内容类型演示Node 18 全局 fetchpackages/react-query/test/octetStreams.test.tsxFile/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覆盖FileNode.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),仅供参考