tRPC 中间件完全指南:用 `.use()` 鉴权、`.concat()` 复用与 `.unstable_pipe()` 编排类型安全的 Procedure 逻辑
导读:本文面向 tRPC 服务端开发者,系统讲解 packages/server/skills/middlewares 技能所覆盖的中间件全貌——从最基本的 t.procedure.use() 挂载与 opts.next() 上下文扩展,到收窄 Context 类型的鉴权中间件、日志与耗时统计模式,再到面向插件化复用的 .concat() 和面向中间件间复用的 .unstable_pipe()。文中所有代码示例均可直接放入仓库内 examples/*/src/server 之类的真实工程运行,并配有 @trpc/server 源码级的执行模型解析,读完后你能独立编写安全、可复用、类型可推断的服务端中间件。
前置准备:初始化 tRPC 根对象并暴露 procedure / middleware
中间件的起点是 tRPC 的“根对象”(root object)。无论项目放在 Express、Fastify、standalone 还是 Next.js 中,服务端都需要先完成一次初始化,把 router、publicProcedure、middleware 从根对象上取出来:
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
type Context = {
user?: { id: string; isAdmin: boolean };
};
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
几点说明:
initTRPC.context<Context>()在类型层面声明了每个请求携带的Context形状;真正在运行时填充它的是各 HTTP 适配器传入的createContext。作为参照,standalone-server 示例 里createContext同时兼容CreateHTTPContextOptions(HTTP)与CreateWSSContextFnOptions(WebSocket)两种入参,把user之类的请求级信息放进ctx。publicProcedure = t.procedure代表“未附加任何中间件”的基础过程构造器,后续所有受保护过程都由它派生。middleware = t.middleware则是独立创建“可复用中间件构造器(MiddlewareBuilder)”的工厂。在 initTRPC.ts 中可以看到,t.middleware就是由createMiddlewareFactory<$Root['ctx'], $Root['meta']>()产生的函数,它会自动携带当前实例的 Context 与 Meta 类型。
核心模式一:收窄 Context 类型的鉴权中间件
中间件最经典的用途是鉴权。下面的 authedProcedure 与 adminProcedure 都建立在 t.procedure.use() 之上:
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
type Context = {
user?: { id: string; isAdmin: boolean };
};
const t = initTRPC.context<Context>().create();
export const publicProcedure = t.procedure;
export const authedProcedure = t.procedure.use(async (opts) => {
const { ctx } = opts;
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
user: ctx.user,
},
});
});
export const adminProcedure = t.procedure.use(async (opts) => {
const { ctx } = opts;
if (!ctx.user?.isAdmin) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
user: ctx.user,
},
});
});
这段代码有两个层次的含义:
- 运行时:中间件先检查
ctx.user(或ctx.user.isAdmin),不满足就抛出TRPCError({ code: 'UNAUTHORIZED' }),请求在进入 resolver 之前被终止;满足则调用opts.next({ ctx: { user } })把已知非空的user继续传下去。 - 类型层:
ctx原本声明为user?: { ... }(可空),但中间件通过opts.next({ ctx: { user: ctx.user } })的返回类型向 TypeScript 声明“后续代码中的user不再可空”。这是 tRPC 中间件最值得称道的一点——鉴权逻辑同时完成运行时保护与类型收窄,经过中间件之后,下游 procedure 与 resolver 里拿到的ctx.user是非空类型。
官方文档对该类型能力的描述与示例一致,见 www/docs/server/middlewares.md 与 www/docs/server/authorization.md。鉴权抛出的错误码 UNAUTHORIZED 属于 TRPCError 内置编码,更多编码与格式化细节可参考 error-handling 技能。
在 router 中组合使用
派生出的受保护过程与普通过程一样参与路由组合:
const adminRouter = router({
secretPlace: adminProcedure.query(() => 'a key'),
});
export const appRouter = router({
foo: publicProcedure.query(() => 'bar'),
admin: adminRouter,
});
每个 router 的键名会拼进 opts.path(例如上面 admin.secretPlace),这正是后面日志中间件输出 path 的依据。authorization.md 中给出的完整对照用法还有“在 resolver 内部手写 if (!opts.ctx.user) throw ...”这种 Option 1,而把校验抽进 protectedProcedure(Option 2)的好处是一处定义、处处复用,并且不再需要在每个 resolver 里重复防御性判断。
鉴权的输入来源:createContext 读取请求头
中间件读取的 ctx.user 通常由 createContext 从请求对象填充。推荐的上下文工厂形如:
import type { CreateHTTPContextOptions } from '@trpc/server/adapters/standalone';
// 示例:解码并校验 JWT 的辅助函数
async function decodeAndVerifyJwtToken(token: string) {
return { name: 'user' };
}
export async function createContext({ req, res }: CreateHTTPContextOptions) {
// 基于请求对象构造上下文,最终会在所有 resolver 的 opts.ctx 中可见
async function getUserFromHeader() {
if (req.headers.authorization) {
const user = await decodeAndVerifyJwtToken(
req.headers.authorization.split(' ')[1],
);
return user;
}
return null;
}
const user = await getUserFromHeader();
return {
user,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
createContext 对每个请求调用一次(standalone-server 示例 在 createHTTPServer 与 WebSocket server 中都会注册它),配合上面的 authedProcedure/adminProcedure 即构成一套完整的鉴权链路。更完整的“中间件 + 客户端请求头”端到端鉴权模式见 auth 技能。
核心模式二:日志与耗时统计中间件
中间件可以包住 opts.next() 前后两段代码,天然适合埋点与计时:
// server/trpc.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
export const loggedProcedure = t.procedure.use(async (opts) => {
const start = Date.now();
const result = await opts.next();
const durationMs = Date.now() - start;
const meta = { path: opts.path, type: opts.type, durationMs };
result.ok
? console.log('OK request timing:', meta)
: console.error('Non-OK request timing', meta);
return result;
});
要点:
await opts.next()之前的代码在 resolver 执行前运行,之后的代码在 resolver(以及它之后的所有中间件)完成后运行,因此可以直接用Date.now()差值得到整个调用链的耗时durationMs。opts.path(如post.list)与opts.type(query/mutation/subscription)是中间件运行时入参的一部分,可用来区分被调用的过程与调用类型。opts.next()返回的是一个带判别联合的结果对象:成功时为{ ok: true, data },失败时为{ ok: false, error },所以上方可以用result.ok分支决定打console.log还是console.error,且无论如何都要return result把结果原样传回给外层(否则类型上就会因为缺少结果标记而编译失败,见下文“中间件执行模型”)。
日志与耗时中间件的思路可进一步延伸到 OpenTelemetry(OTEL)分布式追踪场景:在 opts.next() 前后创建/结束 span,把 path、type 写进 span 属性——这也是该技能把“Logging, timing, OTEL tracing patterns”列为适用面的原因。
核心模式三:Context 扩展与 opts.next() 的合并语义
“Context 扩展(Context Extension)”让中间件能够以类型安全的方式为后续链路上的消费者(后续中间件、resolver)动态追加或覆盖 ctx 的键。前面鉴权中间件其实已经在使用它。
import { initTRPC, TRPCError } from '@trpc/server';
const t = initTRPC.context<Context>().create();
const publicProcedure = t.procedure;
const router = t.router;
type Context = {
user?: {
id: string;
};
};
const protectedProcedure = publicProcedure.use(async function isAuthed(opts) {
const { ctx } = opts;
// 此刻 ctx.user 仍是可空的
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
// 已知 user 非空,重新放回 ctx 完成类型收窄
user: ctx.user,
},
});
});
protectedProcedure.query((opts) => {
const { ctx } = opts;
return ctx.user; // 类型为 { id: string }(非空)
});
从 procedureBuilder.ts 的 callRecursive 可以看到合并的底层实现是浅层展开:
ctx: nextOpts?.ctx ? { ...opts.ctx, ...nextOpts.ctx } : opts.ctx,
也就是说,opts.next({ ctx: { user } }) 不会清空原 Context,而是把新对象展开合并进当前 ctx。这也解释了为什么鉴权中间件只写 { user } 而不会丢失 ctx 中其它字段。
中间件入参与 next 的完整形状
综合 middleware.ts 中 MiddlewareFunction 的定义,每个中间件收到的 opts 包括:
| 字段 | 含义 |
|---|---|
ctx |
当前合并后的上下文,类型为 Simplify<Overwrite<TContext, TContextOverrides>> |
type |
过程类型:query / mutation / subscription |
path |
过程的完整路径,如 admin.secretPlace |
input |
已解析的输入(类型已知) |
getRawInput |
异步读取未经解析的原始输入(如 HTTP body / FormData 原文) |
meta |
该 procedure 附加的 meta 元数据,可能为 undefined |
signal |
请求的 AbortSignal,可为 undefined |
batchIndex |
当前调用在一次批量(batch)请求中的序号 |
next(opts?) |
调用链下一环;可携带 { ctx?, input?, getRawInput? } 覆盖项 |
其中 next 有三个重载形式(无参数、{ ctx?, input? }、{ getRawInput }),返回 Promise<MiddlewareResult>。
什么时候用 getRawInput
opts.input 是经过 .input() 校验器解析后的产物,而 getRawInput() 返回的是 HTTP 层拿到的原始输入,可用于执行“不依赖 schema”的检查(例如签名验签、内容嗅探)。从源码看,getRawInput 可以在 next({ getRawInput }) 中被覆盖,这给了中间件改写后续输入解析来源的能力。同一批中 batchIndex 帮助区分是第几个子请求——批处理场景中这常用于限流/配额等按请求计数的手段(服务器端 batching 的调用约定见 batching.test.ts)。
核心模式四:可复用的中间件与插件化 .concat()
用 t.middleware 创建的中间件有一个天然限制:它的 Context 类型被“绑定”在创建它的那个 tRPC 实例上。想让一段中间件逻辑横跨多个 Context 形状不同、甚至由不同库作者维护的 tRPC 应用,官方推荐使用 .concat():它允许你独立定义一个“部分 procedure”(partial procedure),只要目标 tRPC 实例的 context 与 meta 类型能够满足它,就可以拼接进任意过程链。这一 API 主要面向“用 tRPC 造插件/库”的场景。
库侧:导出一个自带中间件的部分 procedure
// myPlugin.ts
import { initTRPC } from '@trpc/server';
export function createMyPlugin() {
const t = initTRPC.context<{}>().meta<{}>().create();
return {
pluginProc: t.procedure.use((opts) => {
return opts.next({
ctx: {
fromPlugin: 'hello from myPlugin' as const,
},
});
}),
};
}
注意插件自己的根对象使用了 context<{}>().meta<{}>(),即“我要求使用方的 Context 至少包含任意对象、Meta 类型能与此重叠”——这是拼接能成立的类型前提。
应用侧:concat 进自己的基础过程
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import { createMyPlugin } from './myPlugin';
const t = initTRPC.context<{}>().create();
const plugin = createMyPlugin();
export const publicProcedure = t.procedure;
export const procedureWithPlugin = publicProcedure.concat(plugin.pluginProc);
.concat() 会把传入的部分 procedure 合并进当前过程链(包括它的中间件、可选的输入解析器等),前提是 context 与 meta 类型相互重叠。它的实现并不神秘——procedureBuilder.ts 中 concat(builder) 只是把对方 builder 的 _def 交给 createNewBuilder,后者对 middlewares 与 inputs 数组做拼接(见 createNewBuilder 中 middlewares: [...def1.middlewares, ...middlewares] 与 inputs: [...def1.inputs, ...(inputs ?? [])]),同时类型层面通过 TypeError<'Context mismatch'> / TypeError<'Meta mismatch'> 在编译期拒绝不匹配的组合。unstable_concat 是它的旧名称(已标记 deprecated,新代码请用 concat)。
拼接完成后,下游 resolver 能直接使用插件注入的字段:
router({
hello: procedureWithPlugin.query((opts) => opts.ctx.fromPlugin),
});
核心模式五:用 .unstable_pipe() 扩展既有中间件
.concat() 负责“把过程拼进过程”,而 .unstable_pipe() 负责“把中间件拼进中间件”——从一个基础中间件派生出新的中间件构造器,后一段可以读取前一段写入的 ctx,类型全程收窄:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const fooMiddleware = t.middleware((opts) => {
return opts.next({
ctx: { foo: 'foo' as const },
});
});
const barMiddleware = fooMiddleware.unstable_pipe((opts) => {
console.log(opts.ctx.foo);
return opts.next({
ctx: { bar: 'bar' as const },
});
});
const barProcedure = t.procedure.use(barMiddleware);
Pipe 后的中间件按声明顺序依次执行,后一段收到的 ctx 是前一段扩展后的结果。从 middleware.ts 的实现看,每个中间件构造器内部持有一个 _middlewares: MiddlewareFunction[] 数组,unstable_pipe(以及 t.procedure.use)接受“中间件函数”或“中间件构造器”,构造器会被展开成它的 _middlewares 列表再整体拼接。
顺序与类型重叠的约束
Pipe 的顺序会直接影响类型是否成立,官方文档给了一个“禁止方向”的例子:基础 Context 里 a 是一个对象,fooMiddleware 把 a 改写成了字符串 'a',而 barMiddleware 仍期望 ctx.a 是对象——因此 fooMiddleware.unstable_pipe(barMiddleware) 在类型上不允许(编译期 @errors: 2345 报错),反向 barMiddleware.unstable_pipe(fooMiddleware) 则合法:
const t = initTRPC
.context<{
a: {
b: 'a';
};
}>()
.create();
const fooMiddleware = t.middleware((opts) => {
const { ctx } = opts;
ctx.a; // fooMiddleware 期望 ctx.a 是对象
return opts.next({
ctx: {
a: 'a' as const, // ctx.a 不再是对象
},
});
});
const barMiddleware = t.middleware((opts) => {
const { ctx } = opts;
ctx.a; // barMiddleware 期望 ctx.a 是对象
return opts.next({
ctx: {
foo: 'foo' as const,
},
});
});
// ❌ ctx.a 无法从 fooMiddleware 正确传递给 barMiddleware
// fooMiddleware.unstable_pipe(barMiddleware);
// ✅ ctx.a 从 barMiddleware 到 fooMiddleware 仍保持一致
barMiddleware.unstable_pipe(fooMiddleware);
API 说明:
unstable_前缀表示该 API 尚处“可用但可能在主版本演进中调整”的阶段,并不意味着禁止使用;官方对这类 unstable API 的建议是“可以放心在代码中采用”。
源码纵深:中间件的执行模型与 marker 机制
理解 tRPC 中间件背后“为什么会这样工作”,对排查问题非常有帮助。相关核心逻辑集中在两处源码:
- middleware.ts:中间件与结果类型的定义。
- procedureBuilder.ts:构建器与递归执行器。
1. 中间件函数与中间件构造器
MiddlewareFunction 是用户写的形如 async (opts) => MiddlewareResult 的函数;MiddlewareBuilder 则是 t.middleware(fn) 返回的、内部带 _middlewares 数组的构造器。二者的关系是:.use() / .unstable_pipe() 入参两者都接受,源码用 '_middlewares' in middlewareBuilderOrFn 判断后把构造器展开成函数数组再拼接(见 procedureBuilder.ts)。
2. resolver 本质上是“最后一个中间件”
createResolver 在真正挂 resolver 时,会把它包装成一个名为 resolveMiddleware 的中间件,追加到 middlewares 数组末尾(procedureBuilder.ts)。也就是说,一次调用的完整执行顺序是:
middleware1 → middleware2 → … → resolveMiddleware(resolver)
所有中间件通过 callRecursive 以递归方式逐个执行,next() 就是带着新 ctx/input/getRawInput 调用 callRecursive(index + 1, ...)(procedureBuilder.ts)。任一层抛出的未知异常都会被捕获并转换为 TRPCError(借助 getTRPCErrorFromUnknown)。
3. marker:让“忘记 return next()”在编译期就暴露
middleware.ts 定义了一个带品牌类型的字符串常量 middlewareMarker,所有合法的中间件结果(无论 ok: true 还是 ok: false)都必须携带只读的 marker: 'middlewareMarker' 字段。类型注释写得很直白:“Requiring this marker makes sure that can't be forgotten at compile-time”——它把“必须透传 next() 的产物”这条规则固化进了类型系统,是 tRPC 中间件安全性的第一道防线。
4. 输入/输出校验器也是中间件
有意思的是,.input() 与 .output() 在内部同样是中间件:.input(schema) 会追加 createInputMiddleware(parser),.output(schema) 会追加 createOutputMiddleware(parser)(procedureBuilder.ts)。前者负责 await opts.getRawInput() 后做解析,并把解析结果通过 opts.next({ input: combinedInput }) 传给下游;后者在 next() 成功后校验 result.data,失败时抛出 TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Output validation failed' })(middleware.ts)。这就是为什么输入解析失败会表现为 BAD_REQUEST,而输出校验失败会表现为服务端错误。
常见错误排查
[CRITICAL] 忘记调用并返回 opts.next()
错误的写法:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const logMiddleware = t.middleware(async (opts) => {
console.log('request started');
// 忘记调用 opts.next()
});
正确的写法:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const logMiddleware = t.middleware(async (opts) => {
console.log('request started');
const result = await opts.next();
console.log('request ended');
return result;
});
原因解读:中间件必须调用 opts.next() 并返回其结果。如果忘了返回,callRecursive 这一层没有产出带 middlewareMarker 的结果对象,createProcedureCaller 最终会抛出 TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'No result from middlewares - did you forget to return next()?' })——请求被静默地以内部错误终止(procedureBuilder.ts)。同时类型系统也会借由 marker 机制拦住绝大多数这类笔误。
[HIGH] 用错误的类型扩展 Context
错误的写法:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
const middleware = t.middleware(async (opts) => {
return opts.next({ ctx: 'not-an-object' });
});
正确的写法:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create();
async function getUser() {
return { id: '1', name: 'Katt' };
}
const middleware = t.middleware(async (opts) => {
return opts.next({ ctx: { user: await getUser() } });
});
原因解读:opts.next({ ctx }) 中的 ctx 必须是一个对象,因为运行时它要与现有 ctx 做 { ...opts.ctx, ...nextOpts.ctx } 展开合并;传非对象值会让合并结果变得不可预期,进而破坏下游 procedure。同时也要避免在扩展时覆盖掉下游仍需要的必填键(比如把 user 覆盖成 undefined),这会让依赖它的后续中间件/resolver 在类型与运行时同时失去保障。
配套学习路径
- 基础:
initTRPC、router、procedure 与 context 的搭建见 server-setup 技能。 - 输入输出校验(Zod 等 parser)见 validators 技能。
- 鉴权中间件抛出的
TRPCError编码与格式化见 error-handling 技能。 - 结合客户端 header 的完整鉴权落地方案见 auth 技能。
若想阅读一手资料与验证上述结论,建议顺次查看:
- 官方文档正文:www/docs/server/middlewares.md、www/docs/server/authorization.md
- 中间件类型与工厂实现:middleware.ts
- 构建器与递归执行器:procedureBuilder.ts
- 根对象与
t.middleware/t.procedure的装配:initTRPC.ts - 独立中间件行为验证:middlewares.test.ts(覆盖 standalone middleware 的多种 Context 匹配/不匹配场景、中间件链与 pipe)
- 可运行的完整服务端示例:standalone-server/src/server.ts
当你能熟练把“鉴权收窄类型、计时观察性能、concat 拼装插件、pipe 编排中间件”这四板斧组合起来后,tRPC 的路由层就会变成一套既安全又高度内聚的声明式管线。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python08
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00