尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

tRPC v11 中间件完全指南:鉴权、日志、上下文扩展与插件化开发

tRPC v11 中间件完全指南:鉴权、日志、上下文扩展与插件化开发 tRPC v11 中间件完全指南鉴权、日志、上下文扩展与插件化开发【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc本篇文章以 tRPC 仓库当前工作目录根即仓库根中 www/docs/server/middlewares.md 为主体系统讲解 tRPC Server 端中间件的核心机制与全部官方用法。你将掌握如何用t.procedure.use()为 procedure 叠加鉴权、日志等能力如何利用“上下文扩展”做类型安全的 Context 改写以及如何通过.concat()、.unstable_pipe()把中间件抽象为可跨 tRPC 实例复用的库级插件文中同步对照 packages/server/src/unstable-core-do-not-import/middleware.ts 与 procedureBuilder.ts 的源码实现让每一个 API 都有原理可循。中间件是什么包装一次 procedure 调用的执行链在 tRPC 中中间件是挂载在 procedure 上的执行包装器。你可以通过t.procedure.use()为某个 procedure 叠加一个或多个中间件这些中间件会依次“包裹”procedure 的真实调用每个中间件都能在调用前做校验或准备、在调用后观察结果并且必须调用opts.next()并把它的返回值原样返回否则整条调用链会在这里断开。从实现上看use()本质上是在往 procedure 的_def.middlewares数组里追加条目参见 procedureBuilder.ts#L526-L536use(middlewareBuilderOrFn) { // Distinguish between a middleware builder and a middleware function const middlewares _middlewares in middlewareBuilderOrFn ? middlewareBuilderOrFn._middlewares : [middlewareBuilderOrFn]; return createNewBuilder(_def, { middlewares: middlewares, }); }随后在真正执行时procedureBuilder.ts#L634-L672 的callRecursive会从下标 0 开始递归地按注册顺序运行中间件并把当前 procedure 的解析器resolver作为链上最后一个“next”包装进中间件数组async function callRecursive(index, _def, opts) { const middleware _def.middlewares[index]!; const result await middleware({ ...opts, meta: _def.meta, input: opts.input, next(_nextOpts?) { return callRecursive(index 1, _def, { ...opts, ctx: nextOpts?.ctx ? { ...opts.ctx, ...nextOpts.ctx } : opts.ctx, input: nextOpts input in nextOpts ? nextOpts.input : opts.input, getRawInput: nextOpts?.getRawInput ?? opts.getRawInput, }); }, }); return result; }这段源码还揭示了两个容易被忽视的细节错误被归一为{ ok: false, error }的结果。中间件内抛出的任何异常包括TRPCError都会被捕获最终由调用方 re-throw 成 tRPC 错误——参见 middleware.ts#L21-L38 对MiddlewareResultok: true/ok: false两种分支的定义以及MiddlewareOKResult上强制要求的marker字段——官方特意用编译期 marker 约束“所有中间件都应透传next()的输出不能被遗忘”。next({ ctx })不是整体替换而是浅合并。递归调用时执行的是ctx: { ...opts.ctx, ...nextOpts.ctx }所以你传给next()的 ctx 只需要写“新增/覆盖的那部分键”其余上下文会自动保留。一个中间件函数的完整签名MiddlewareFunction定义在 middleware.ts#L89-L120它拿到的opts包含字段含义ctx当前上下文OverwriteTContext, TContextOverrides之后的类型typeprocedure 类型query/mutation/subscriptionpath当前被调用 procedure 的完整路径input已解析校验通过的输入getRawInput延迟获取原始输入的函数配合批处理时很有用meta该 procedure 关联的 meta 数据类型为TMeta \| undefinedsignal请求的AbortSignal用于取消batchIndex当前调用在批量请求中的下标next调用链的下一环可传{ ctx?, input?, getRawInput? }下面逐一展开文档中的官方示例并补充源码佐证。实战一鉴权中间件Authorization最常见的中间件用途是访问控制。下面是官方文档给出的“管理员 procedure”示例任何走adminProcedure的调用在真正执行前都会先校验ctx.user.isAdmin不满足则直接抛出UNAUTHORIZED错误。import { TRPCError, initTRPC } from trpc/server; interface Context { user?: { id: string; isAdmin: boolean; // [..] }; } const t initTRPC.contextContext().create(); export const publicProcedure t.procedure; export const router t.router; export const adminProcedure publicProcedure.use(async (opts) { const { ctx } opts; if (!ctx.user?.isAdmin) { throw new TRPCError({ code: UNAUTHORIZED }); } return opts.next({ ctx: { user: ctx.user, }, }); });注意这里即便不改写任何上下文也习惯性把ctx.user原样放回next({ ctx: ... })——这样做是为了获得类型提升外层Context.user是可选nullable的而一旦通过了中间件校验内层 procedure 拿到手的就是非空user。随后把“受保护的能力”组装进 routerimport { adminProcedure, publicProcedure, router } from ./trpc; const adminRouter router({ secretPlace: adminProcedure.query(() a key), }); export const appRouter router({ foo: publicProcedure.query(() bar), admin: adminRouter, });这样appRouter.foo可匿名访问而appRouter.admin.secretPlace必须先通过管理员校验。若鉴权失败想返回 401/403 等带语义的 HTTP 状态码请深入了解TRPCError的完整错误码体系参见仓库文档 www/docs/server/error-handling.md。实战二日志与耗时统计中间件Logging中间件天然适合做横切关注点。下面的中间件会自动记录每次 query/mutation 的调用耗时并利用result.ok区分成功与失败——这正是MiddlewareResult联合类型里ok标志在业务侧的典型用法import { initTRPC } from trpc/server; const t initTRPC.create(); export const publicProcedure t.procedure; export const router t.router; export const loggedProcedure publicProcedure.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; });组装进 router 后foo、abc两个 procedure 的每次调用都会自动输出耗时日志import { loggedProcedure, router } from ./trpc; export const appRouter router({ foo: loggedProcedure.query(() bar), abc: loggedProcedure.query(() def), });这里的关键点在于next()返回的result必须先await再读取ok、data或error。结合 middleware.ts 的类型定义ok: true的分支携带dataok: false的分支携带error: TRPCError。记录完日志后必须return result把结果继续向外传递否则上层消费者拿不到数据。实战三上下文扩展Context Extension“Context Extension”是 tRPC 中间件最核心的类型能力中间件可以在next({ ctx })中动态新增或覆盖 base procedure 上下文中的键而这些变化会以类型安全的方式自动广播给整条链上的后续消费者——包括后面的其他中间件以及最终 procedure 的 resolver。官方示例用“登录保护”来演示这一点import { initTRPC, TRPCError } from trpc/server; type Context { // user is nullable user?: { id: string; }; }; const t initTRPC.contextContext().create(); const publicProcedure t.procedure; const router t.router; const protectedProcedure publicProcedure.use(async function isAuthed(opts) { const { ctx } opts; // ctx.user is nullable if (!ctx.user) { throw new TRPCError({ code: UNAUTHORIZED }); } return opts.next({ ctx: { // ✅ user value is known to be non-null now user: ctx.user, }, }); }); protectedProcedure.query((opts) { const { ctx } opts; return ctx.user; // ✅ 类型上这里已经是非空 user });类型层面发生的事可以从源码窥见一二use()的泛型签名通过OverwriteTContextOverrides, $ContextOverridesOut累积上下文覆盖见 middleware.ts#L52-L72 的unstable_pipe签名与 procedureBuilder.ts#L259-L283 的use签名而运行时则靠callRecursive里的{ ...opts.ctx, ...nextOpts.ctx }浅合并逐层生效。所以“类型”与“运行时”是同步演进、一一对应的。对应的类型级测试也可以在 packages/server/src/unstable-core-do-not-import/procedureBuilder.test.ts#L62-L99 中找到——测试用expectTypeOf(opts.ctx.user).toEqualTypeOfUser()精确断言经过 authed 中间件后procedure 内的ctx.user已从 nullable 收窄为非空。实战四用.concat()打造跨实例复用的插件中间件为什么要用.concat()中间件复用通常有两种朴素做法但各有局限官方文档明确给出了这两条提示用t.middleware创建中间件它的Context类型被绑定死在创建它的那个 tRPC 实例的Context类型上无法直接拿到别的 tRPC 实例上下文不同使用。用experimental_standaloneMiddleware()它脱离实例但反过来没法定义与自身模块绑定的 input parser等能力。.concat()正是为打通这两个痛点而生的 API它允许你独立定义一个“局部 procedure”partial procedure通常是一个已经use了中间件、甚至带了.input()的 builder只要使用方的 tRPC 实例满足该插件声明的 Context 与 meta 约束就能通过.concat()把这段能力合并进来。从源码看concat(builder)的实现就是把它所携带的 builder 的_def含inputs、middlewares、meta等合并进当前 builderconcat(builder) { return createNewBuilder(_def, (builder as AnyProcedureBuilder)._def); }, // deprecated use {link concat} instead unstable_concat(builder) { return createNewBuilder(_def, (builder as AnyProcedureBuilder)._def); },见 procedureBuilder.ts#L537-L542旧名unstable_concat现已被concat取代并标记为 deprecated。合并的核心逻辑在createNewBuilderprocedureBuilder.ts#L471-L484middlewares 采用追加合并inputs 采用数组拼接meta 则按{ ...meta, ...新meta }覆盖。一个完整的插件化示例下面完整复刻官方示例。先看“插件库”侧——插件作者用initTRPC创建自己的根t对象并在context()/meta()的类型参数中声明“使用本插件的最低要求”再导出一个已绑定了中间件可选加.input()的pluginProc// myPlugin.ts —— 一个库创建可复用的插件 import { initTRPC, TRPCError } from trpc/server; export function createMyPlugin() { // 创建 tRPC 插件与创建普通 tRPC 应用用的是同一套 API // 这是插件的根 t 对象 const t initTRPC .context{ // 使用该插件的 procedure其 Context 必须能扩展进这些键 }() .meta{ // 使用该插件的应用的根 initTRPC 对象其 Meta 需包含这些键 }() .create(); return { // 若希望插件做输入校验还可以在这里链式调用 .input() pluginProc: t.procedure.use((opts) { return opts.next({ ctx: { fromPlugin: hello from myPlugin as const, }, }); }), }; }再看“应用”侧——应用只声明自己的 Context然后publicProcedure.concat(plugin.pluginProc)即可获得插件注入的上下文// app.ts —— 使用插件的应用 import { createMyPlugin } from ./myPlugin; import { initTRPC, TRPCError } from trpc/server; // 应用的根 t 对象 const t initTRPC .context{ // ... }() .create(); export const publicProcedure t.procedure; export const router t.router; // 初始化插件真实插件通常会在这里接收 options const plugin createMyPlugin(); // 用插件创建一条基础 procedure const procedureWithPlugin publicProcedure .concat(plugin.pluginProc) .use((opts) { const { ctx } opts; // 这里 ctx.fromPlugin 已被类型系统识别为 hello from myPlugin return opts.next(); }); export const appRouter router({ hello: procedureWithPlugin.query((opts) { return opts.ctx.fromPlugin; // ✅ 插件注入的字段 }), });这个示例最能体现.concat()的设计意图它主要面向“用 tRPC 创建插件与库”的开发场景。官方在文档中还留了 TODO希望后续补充一个真实插件的完整示例但从 packages/tests/server/middlewares.test.ts 的standalone middleware与pipe middlewares测试组以及 procedureBuilder.test.ts#L233-L509 的concat()测试组含“concat with default values”用例可以看出concat会在合并后保留被合并 builder 的 input 解析器、default 值等完整信息行为是有测试保障的。实战五用.unstable_pipe()类型安全地扩展中间件.concat()合并的是两个 procedure builder而.pipe()解决的则是“扩展单个中间件”的场景。:::info 稳定程度说明.pipe()目前在 API 上带有unstable_前缀属于较新的 API但官方明确表示可以放心使用。关于unstable_前缀的含义可参考仓库 FAQ 文档 www/docs/further/faq.mdx。 :::它的用法是fooMiddleware.unstable_pipe((opts) {...})从而产生一个新的中间件。与前面的“上下文扩展”类似被 pipe 出来的新中间件同样会改写 Context而最终挂载它的 procedure 会拿到新 Context 的类型。官方示例import { initTRPC, TRPCError } from trpc/server; const t initTRPC.create(); const publicProcedure t.procedure; const router t.router; const middleware t.middleware; const fooMiddleware t.middleware((opts) { return opts.next({ ctx: { foo: foo as const, }, }); }); const barMiddleware fooMiddleware.unstable_pipe((opts) { const { ctx } opts; // 这里能读到上游 pipe 注入的 ctx.foo类型为 foo return opts.next({ ctx: { bar: bar as const, }, }); }); const barProcedure publicProcedure.use(barMiddleware); barProcedure.query((opts) { const { ctx } opts; return ctx.bar; // ✅ 类型为 bar });从源码看unstable_pipe的实现非常朴素——它把“已积累的中间件列表”继续往后追加unstable_pipe(middlewareBuilderOrFn) { const pipedMiddleware _middlewares in middlewareBuilderOrFn ? middlewareBuilderOrFn._middlewares : [middlewareBuilderOrFn]; return createMiddlewareInner([...middlewares, ...pipedMiddleware]); }见 middleware.ts#L137-L144。注意_middlewares数组的存在无论是.use()、.concat()还是.unstable_pipe()底层都以“展开中间件函数数组”的方式工作所以一个中间件 builder比如barMiddleware也可以整体作为函数传给另一条链路。pipe 的顺序与 Context 覆盖规则官方文档特别警告了两点pipe 的顺序会影响最终 Context且前后 Context 必须“有重叠”。下面的例子中根 Context 的a是对象类型fooMiddleware把ctx.a覆盖成了字符串而barMiddleware仍然期望a是对象——于是“foo 在前、bar 在后”就构成了非法 pipeimport { initTRPC } from trpc/server; 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);为什么会报错因为类型层面 pipe 要求“前一个中间件输出的上下文类型”必须能赋值给“后一个中间件输入期望的上下文类型”。这与 middleware.ts#L52-L72 里unstable_pipe对$ContextOverridesOut的泛型约束一一对应。运行时顺序测试如 packages/tests/server/middlewares.test.ts 中pipe middlewares - inlined、pipe middlewares - override、pipe middlewares - failure等用例也验证了中间件确实按 pipe 的顺序依次包裹执行且后写覆盖先写。附录废弃的experimental_standaloneMiddleware:::warning 已废弃experimental_standaloneMiddleware已不再推荐使用官方建议新代码一律使用.concat()。它的源码声明处也带着deprecated use .concat() instead注释见 middleware.ts#L163-L180。 :::之所以曾在文档中保留专节是因为它解决过同一个痛点t.middleware创建的中间件 Context 类型被绑定在某个 tRPC 实例上无法跨不同 Context 类型的实例复用。experimental_standaloneMiddleware允许你显式声明中间件的需求——即它依赖的ctx、input、meta三种类型然后任何一个满足该需求的 tRPC 实例都能安全接入import { experimental_standaloneMiddleware, initTRPC, TRPCError, } from trpc/server; import * as z from zod; const projectAccessMiddleware experimental_standaloneMiddleware{ ctx: { allowedProjects: string[] }; // 不声明则默认 object input: { projectId: string }; // 不声明则默认 unknown // meta 此处未声明默认为 object | undefined }().create((opts) { if (!opts.ctx.allowedProjects.includes(opts.input.projectId)) { throw new TRPCError({ code: FORBIDDEN, message: Not allowed, }); } return opts.next(); }); const t1 initTRPC .context{ allowedProjects: string[]; }() .create(); // ✅ ctx.allowedProjects 满足 string[]input.projectId 满足 string const accessControlledProcedure t1.procedure .input(z.object({ projectId: z.string() })) .use(projectAccessMiddleware); // ❌ 报错input.projectId 类型是 number不满足 string // const accessControlledProcedure2 t1.procedure // .input(z.object({ projectId: z.number() })) // .use(projectAccessMiddleware); // ❌ 报错ctx.allowedProjects 是 number[]不满足 string[] // const t2 initTRPC // .context{ allowedProjects: number[] }() // .create(); // const accessControlledProcedure3 t2.procedure // .input(z.object({ projectId: z.string() })) // .use(projectAccessMiddleware);实现上experimental_standaloneMiddleware的泛型约束接收{ ctx?, meta?, input? }并用条件类型把缺省值兜底为object/object/unknown见 middleware.ts#L168-L180。它同样可以链式叠加多个——官方示例中两个分别对大写化valueA、valueB的独立中间件配合一个同时满足二者输入的 zod schema就能合并成一条组合 procedureimport { experimental_standaloneMiddleware, initTRPC } from trpc/server; import * as z from zod; const t initTRPC.create(); const schemaA z.object({ valueA: z.string() }); const schemaB z.object({ valueB: z.string() }); const valueAUppercaserMiddleware experimental_standaloneMiddleware{ input: z.infertypeof schemaA; }().create((opts) { return opts.next({ ctx: { valueAUppercase: opts.input.valueA.toUpperCase() }, }); }); const valueBUppercaserMiddleware experimental_standaloneMiddleware{ input: z.infertypeof schemaB; }().create((opts) { return opts.next({ ctx: { valueBUppercase: opts.input.valueB.toUpperCase() }, }); }); const combinedInputThatSatisfiesBothMiddlewares z.object({ valueA: z.string(), valueB: z.string(), extraProp: z.string(), }); t.procedure .input(combinedInputThatSatisfiesBothMiddlewares) .use(valueAUppercaserMiddleware) .use(valueBUppercaserMiddleware) .query( ({ input: { valueA, valueB, extraProp }, ctx: { valueAUppercase, valueBUppercase }, }) valueA: ${valueA}, valueB: ${valueB}, extraProp: ${extraProp}, valueAUppercase: ${valueAUppercase}, valueBUppercase: ${valueBUppercase}, );这个组合示例同时演示了“多个中间件顺次.use()叠加”的通用规则中间件的 ctx 改写会层层累积最终 procedure 可以同时拿到两个中间件注入的valueAUppercase与valueBUppercase。总结如何选择中间件的组合手段把官方文档与源码实现对应起来可以归纳出三条清晰的选择准则需求推荐手段备注给本实例的某些 procedure 加鉴权/日志/埋点publicProcedure.use(fn)单实例内最常用需要类型安全的 Context 增改并下发给链上在use(fn)的next({ ctx })中改写浅合并只写增量键写可被任意 tRPC 实例复用的插件/库.concat(pluginProc)替代已废弃的experimental_standaloneMiddleware在单个中间件基础上继续扩展并保类型fooMiddleware.unstable_pipe(...)注意顺序与 Context 重叠想进一步把中间件体系接入业务还可继续阅读同目录的姊妹文档上下文与初始化、procedure 定义与输入校验、错误处理与 TRPCError 以及 metadata想阅读更完整的运行级测试可查看 packages/tests/server/middlewares.test.ts其中覆盖了独立中间件、pipe内联/独立/失败/覆盖与 meta 等场景是理解中间件运行时行为的最佳参考。【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表