首页
/ tRPC 中间件完全指南:用 `.use()` 鉴权、`.concat()` 复用与 `.unstable_pipe()` 编排类型安全的 Procedure 逻辑

tRPC 中间件完全指南:用 `.use()` 鉴权、`.concat()` 复用与 `.unstable_pipe()` 编排类型安全的 Procedure 逻辑

2026-09-08 09:26:54作者:伍霜盼Ellen

导读:本文面向 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 中,服务端都需要先完成一次初始化,把 routerpublicProceduremiddleware 从根对象上取出来:

// 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 类型的鉴权中间件

中间件最经典的用途是鉴权。下面的 authedProcedureadminProcedure 都建立在 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,
    },
  });
});

这段代码有两个层次的含义:

  1. 运行时:中间件先检查 ctx.user(或 ctx.user.isAdmin),不满足就抛出 TRPCError({ code: 'UNAUTHORIZED' }),请求在进入 resolver 之前被终止;满足则调用 opts.next({ ctx: { user } }) 把已知非空的 user 继续传下去。
  2. 类型层ctx 原本声明为 user?: { ... }(可空),但中间件通过 opts.next({ ctx: { user: ctx.user } }) 的返回类型向 TypeScript 声明“后续代码中的 user 不再可空”。这是 tRPC 中间件最值得称道的一点——鉴权逻辑同时完成运行时保护与类型收窄,经过中间件之后,下游 procedure 与 resolver 里拿到的 ctx.user 是非空类型

官方文档对该类型能力的描述与示例一致,见 www/docs/server/middlewares.mdwww/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.typequery / mutation / subscription)是中间件运行时入参的一部分,可用来区分被调用的过程与调用类型。
  • opts.next() 返回的是一个带判别联合的结果对象:成功时为 { ok: true, data },失败时为 { ok: false, error },所以上方可以用 result.ok 分支决定打 console.log 还是 console.error,且无论如何都要 return result 把结果原样传回给外层(否则类型上就会因为缺少结果标记而编译失败,见下文“中间件执行模型”)。

日志与耗时中间件的思路可进一步延伸到 OpenTelemetry(OTEL)分布式追踪场景:在 opts.next() 前后创建/结束 span,把 pathtype 写进 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.tscallRecursive 可以看到合并的底层实现是浅层展开:

ctx: nextOpts?.ctx ? { ...opts.ctx, ...nextOpts.ctx } : opts.ctx,

也就是说,opts.next({ ctx: { user } }) 不会清空原 Context,而是把新对象展开合并进当前 ctx。这也解释了为什么鉴权中间件只写 { user } 而不会丢失 ctx 中其它字段。

中间件入参与 next 的完整形状

综合 middleware.tsMiddlewareFunction 的定义,每个中间件收到的 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.tsconcat(builder) 只是把对方 builder 的 _def 交给 createNewBuilder,后者对 middlewaresinputs 数组做拼接(见 createNewBuildermiddlewares: [...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 是一个对象,fooMiddlewarea 改写成了字符串 '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 中间件背后“为什么会这样工作”,对排查问题非常有帮助。相关核心逻辑集中在两处源码:

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 在类型与运行时同时失去保障。


配套学习路径

若想阅读一手资料与验证上述结论,建议顺次查看:

当你能熟练把“鉴权收窄类型、计时观察性能、concat 拼装插件、pipe 编排中间件”这四板斧组合起来后,tRPC 的路由层就会变成一套既安全又高度内聚的声明式管线。

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

项目优选

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