首页
/ tRPC Fastify 适配器实战:用 fastifyTRPCPlugin 搭建支持 WebSocket 订阅的全类型安全 API 服务

tRPC Fastify 适配器实战:用 fastifyTRPCPlugin 搭建支持 WebSocket 订阅的全类型安全 API 服务

2026-09-05 11:04:25作者:宗隆裙

本文以 tRPC 仓库自带的 Fastify 示例(examples/fastify-server)为主体,完整讲解如何基于 @trpc/server/adapters/fastifyfastifyTRPCPlugin 搭建一个同时支持 HTTP 请求与 WebSocket 订阅的 tRPC 服务,并在 Node 侧用 @trpc/clientsplitLink 将订阅流量分流到 wsLink、其余流量走 httpBatchLink。读完后你可以独立复现这套「Fastify + WebSocket 订阅 + 类型安全客户端」的完整链路,并理解适配器底层的实现细节。

示例总览:这个 Fastify 示例包含什么

官方示例 README 对示例的定位很简洁:一个带 WebSocket 的 Fastify 服务器,外加一个运行在 Node 中的简单 tRPC 客户端。从源码目录结构看,示例由四部分组成:

  • 服务端server.ts 负责创建 Fastify 实例并注册 tRPC 插件与 WebSocket 支持;index.ts 是服务入口;
  • 路由层router/index.ts 聚合了 postssubapi 三个子路由,覆盖 mutation、query 和 subscription 三类过程;
  • 客户端client/index.ts 演示了如何通过 splitLink 将 HTTP 与 WebSocket 两种链路组合进同一个客户端;
  • 共享配置config.ts 是服务端与客户端共同消费的运行参数(端口、前缀)。

服务端依赖在 package.json 中声明,核心版本约束为:fastify ^5.0.0、@fastify/websocket ^11.0.0、ws ^8.0.0、superjson ^1.12.4、zod ^4.2.1,客户端与服务端包均来自 monorepo 工作区(npm:@trpc/clientnpm:@trpc/server)。

服务端搭建:createServer 与 fastifyTRPCPlugin

核心搭建逻辑位于 server.ts

import ws from '@fastify/websocket';
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
import fastify from 'fastify';
import { appRouter } from './router';
import { createContext } from './router/context';

export interface ServerOptions {
  dev?: boolean;
  port?: number;
  prefix?: string;
}

export function createServer(opts: ServerOptions) {
  const dev = opts.dev ?? true;
  const port = opts.port ?? 3000;
  const prefix = opts.prefix ?? '/trpc';
  const server = fastify({ logger: dev });

  void server.register(ws);
  void server.register(fastifyTRPCPlugin, {
    prefix,
    useWSS: true,
    trpcOptions: { router: appRouter, createContext },
  });

  server.get('/', async () => {
    return { hello: 'wait-on 💨' };
  });

  const start = async () => {
    try {
      await server.listen({ port });
      console.log('listening on port', port);
    } catch (err) {
      server.log.error(err);
      process.exit(1);
    }
  };
  const stop = async () => {
    await server.close();
  };

  return { server, start, stop };
}

关键参数说明:

  • prefix(默认 /trpc:所有 tRPC 路由挂载的前缀,HTTP 请求会打到 ${prefix}/:path,WebSocket 升级请求也使用同一前缀。示例中配置为 /trpc,客户端连接地址即 http://localhost:2022/trpc
  • useWSS: true:开启 WebSocket 订阅能力,这是本示例区别于普通 HTTP 适配器的核心开关;
  • trpcOptions:透传给请求处理器的 routercreateContext,与 HTTP 适配器(如 Express、Node HTTP 适配器)的参数形态保持一致;
  • dev / port:控制 Fastify 日志开关与监听端口,默认 dev=trueport=3000

实际运行参数由 config.ts 提供,服务端与客户端共用同一份配置,保证两端端口与前缀一致:

export const serverConfig: ServerOptions = {
  dev: false,
  port: 2022,
  prefix: '/trpc',
};

入口 server/index.ts 只做两件事:createServer(serverConfig) 后调用 server.start()

适配器底层做了什么

从源码结构看,fastifyTRPCPlugin 的实现位于 fastifyTRPCPlugin.ts,它做了三件关键事情:

  1. 重写内容解析器:移除 Fastify 默认的 application/jsonmultipart/form-data 解析器,改为以字符串形式透传请求体(parseAs: 'string')。这样做是为了让 tRPC 处理器自己决定如何解析/校验输入,避免 Fastify 提前消费 body;
  2. 注册通配路由fastify.all(${prefix}/:path, ...) 将所有匹配前缀下的路径统一交给 fastifyRequestHandler 处理(见 fastifyRequestHandler.ts),这与 tRPC「单端点、按路径分发过程」的 RPC 模型相符;
  3. 注册 WebSocket 端点:当 useWSS 为 true 时,插件基于 @fastify/websocket 注册 fastify.get(prefix ?? '/', { websocket: true }, ...) 端点,并通过 getWSConnectionHandler(来自 ws.ts)把每个 WebSocket 连接接到 tRPC 的订阅处理器上。

插件的类型定义也值得注意:CreateFastifyContextOptionsNodeHTTPCreateContextFnOptions<FastifyRequest, FastifyReply>,意味着 context 函数拿到的 req/res 是标准 Fastify 请求/响应对象。

路由与 Context:posts、sub、api 三个子路由

router/index.ts 将三个子路由聚合为 appRouter,并导出 AppRouter 类型——这是客户端获得端到端类型推导的前提:

export const appRouter = router({
  posts: postsRouter,
  sub: subRouter,
  api: apiRouter,
});

export type AppRouter = typeof appRouter;

tRPC 实例在 trpc.ts 中创建,使用了 superjson 作为 transformer(支持 Date、Map 等非 JSON 原生类型序列化),并定义了空实现的 errorFormatter

const t = initTRPC.context<Context>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return shape;
  },
});

Context 在 context.ts 中从请求头提取用户信息——username 请求头缺失时回退为 'anonymous'

export function createContext(opts: CreateFastifyContextOptions) {
  const user: User = { name: opts.req.headers.username ?? 'anonymous' };
  return { req: opts.req, res: opts.res, user };
}

posts 路由:带授权的 mutation

posts.ts 用内存数组模拟数据库,提供 createlistreset 三个过程。其中 create 演示了基于 context 的简单授权:只有 username'nyan' 的请求才允许创建帖子,否则抛出 UNAUTHORIZED 类型的 TRPCError;输入则用 zod 的 z.object({ title: z.string() }) 校验。

sub 路由:可取消的订阅

sub.ts 基于 @trpc/server/observableobservable 工具定义订阅过程,每秒 emit.next 一个随机数,并在清理函数中 clearInterval 停止定时器——这演示了订阅生命周期中服务端资源释放的标准写法。

api 路由:query 与输入回退

api.ts 提供 versionhello 两个查询,其中 hello 的输入是 z.object({ username: z.string().nullish() }).nullish(),响应文本按 input.username → ctx.user.name → 'world' 三级回退,正好串联了「显式输入」与「context 推导」两种数据来源。

客户端搭建:splitLink 分流 WebSocket 与 HTTP

客户端 client/index.ts 是示例的另一半,核心是 splitLink 按操作类型分流两条链路:

const { port, prefix } = serverConfig;
const urlEnd = `localhost:${port}${prefix}`;
const wsClient = createWSClient({ url: `ws://${urlEnd}` });
const trpc = createTRPCClient<AppRouter>({
  links: [
    splitLink({
      condition(op) {
        return op.type === 'subscription';
      },
      true: wsLink({ client: wsClient, transformer: superjson }),
      false: httpBatchLink({
        url: `http://${urlEnd}`,
        transformer: superjson,
      }),
    }),
  ],
});

要点:

  • 分流条件op.type === 'subscription' 的操作走 wsLink(WebSocket),其余(query/mutation)走 httpBatchLink(HTTP,支持批处理);
  • transformer 两端一致:HTTP 链路与 WS 链路都必须使用与服务端相同的 superjson,否则序列化格式不匹配;
  • URL 构造:HTTP 用 http://${urlEnd},WebSocket 用 ws://${urlEnd},共用 port + prefix
  • 优雅退出:客户端跑完示例流程后显式 await wsClient.close(),保证 Node 进程能干净退出。

客户端演示流程依次执行:api.version 查询 → api.hello 查询 → posts.list 查询 → posts.reset mutation → 订阅 sub.randomNumber,收到 4 条数据后 sub.unsubscribe() 并解析 Promise。注意由于没有传 username 请求头,此客户端在整个流程中以 anonymous 身份运行——若直接调用 posts.create 会触发上面提到的 UNAUTHORIZED

本地运行方式

README 给出的官方步骤是先在 monorepo 根目录安装并启动开发环境,再进入示例目录安装依赖并运行。由于本仓库已提供完整源码,也可以直接按 package.json 中的 scripts 手动执行,等价流程如下:

  1. 克隆主仓库并安装依赖(原 README 使用的命令):

    git clone git@github.com:trpc/trpc.git
    cd ./trpc
    yarn
    yarn dev
    
  2. 安装示例依赖并以开发模式运行(dev 脚本通过 npm-run-all 并行启动服务端与客户端,客户端用 wait-port 2022 等待服务就绪):

    cd ./examples/fastify-server
    yarn
    yarn dev
    
  3. 也可以先构建产物再从全新构建启动(build 使用 esbuild 将服务端与客户端分别打包为 ESM 到 dist/start 同样并行运行两端):

    yarn build
    yarn start
    

package.json 中与运行相关的 scripts 一览:

脚本 命令 说明
dev:server tsx watch src/server 热重载启动 TypeScript 服务端
dev:client wait-port 2022 && tsx watch src/client 等待 2022 端口就绪后启动客户端
dev run-p dev:* --print-label 并行运行上述两者
build esbuild ... --format=esm --outdir=dist 打包服务端与客户端到 dist/
start run-p start:* --print-label 并行运行构建产物(客户端同样 wait-port 2022
test-dev / test-start start-server-and-test ... http-get://localhost:2022 ... 先探测 2022 端口 HTTP 可达,再执行客户端冒烟验证

其中 test-devstart-server-and-testhttp-get://localhost:2022 做健康检查后才执行客户端,这与服务端 server.ts 中注册的 GET / 返回 { hello: 'wait-on 💨' } 的兜底路由相配合。

小结

这个示例是理解 tRPC Fastify 适配器的一条最短完整路径:服务端用 fastifyTRPCPlugin 一个插件同时注册 HTTP 通配路由与 WebSocket 订阅端点,客户端用 splitLink 把订阅与常规调用分流到 wsLinkhttpBatchLink,两端通过共享的 AppRouter 类型与 superjson transformer 保持类型和序列化一致。若需要扩展自己的服务,建议从 config.ts 的参数入手调整前缀与端口,再按 routers 目录 中 query/mutation/subscription 的既有模式添加自己的过程;更底层的插件行为可以参考 fastifyTRPCPlugin.tsfastifyRequestHandler.ts 的实现。

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