tRPC Fastify 适配器实战:用 fastifyTRPCPlugin 搭建支持 WebSocket 订阅的全类型安全 API 服务
本文以 tRPC 仓库自带的 Fastify 示例(examples/fastify-server)为主体,完整讲解如何基于 @trpc/server/adapters/fastify 的 fastifyTRPCPlugin 搭建一个同时支持 HTTP 请求与 WebSocket 订阅的 tRPC 服务,并在 Node 侧用 @trpc/client 的 splitLink 将订阅流量分流到 wsLink、其余流量走 httpBatchLink。读完后你可以独立复现这套「Fastify + WebSocket 订阅 + 类型安全客户端」的完整链路,并理解适配器底层的实现细节。
示例总览:这个 Fastify 示例包含什么
官方示例 README 对示例的定位很简洁:一个带 WebSocket 的 Fastify 服务器,外加一个运行在 Node 中的简单 tRPC 客户端。从源码目录结构看,示例由四部分组成:
- 服务端:server.ts 负责创建 Fastify 实例并注册 tRPC 插件与 WebSocket 支持;index.ts 是服务入口;
- 路由层:router/index.ts 聚合了
posts、sub、api三个子路由,覆盖 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/client、npm:@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:透传给请求处理器的router与createContext,与 HTTP 适配器(如 Express、Node HTTP 适配器)的参数形态保持一致;dev/port:控制 Fastify 日志开关与监听端口,默认dev=true、port=3000。
实际运行参数由 config.ts 提供,服务端与客户端共用同一份配置,保证两端端口与前缀一致:
export const serverConfig: ServerOptions = {
dev: false,
port: 2022,
prefix: '/trpc',
};
入口 server/index.ts 只做两件事:createServer(serverConfig) 后调用 server.start()。
适配器底层做了什么
从源码结构看,fastifyTRPCPlugin 的实现位于 fastifyTRPCPlugin.ts,它做了三件关键事情:
- 重写内容解析器:移除 Fastify 默认的
application/json与multipart/form-data解析器,改为以字符串形式透传请求体(parseAs: 'string')。这样做是为了让 tRPC 处理器自己决定如何解析/校验输入,避免 Fastify 提前消费 body; - 注册通配路由:
fastify.all(${prefix}/:path, ...)将所有匹配前缀下的路径统一交给fastifyRequestHandler处理(见 fastifyRequestHandler.ts),这与 tRPC「单端点、按路径分发过程」的 RPC 模型相符; - 注册 WebSocket 端点:当
useWSS为 true 时,插件基于@fastify/websocket注册fastify.get(prefix ?? '/', { websocket: true }, ...)端点,并通过getWSConnectionHandler(来自 ws.ts)把每个 WebSocket 连接接到 tRPC 的订阅处理器上。
插件的类型定义也值得注意:CreateFastifyContextOptions 即 NodeHTTPCreateContextFnOptions<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 用内存数组模拟数据库,提供 create、list、reset 三个过程。其中 create 演示了基于 context 的简单授权:只有 username 为 'nyan' 的请求才允许创建帖子,否则抛出 UNAUTHORIZED 类型的 TRPCError;输入则用 zod 的 z.object({ title: z.string() }) 校验。
sub 路由:可取消的订阅
sub.ts 基于 @trpc/server/observable 的 observable 工具定义订阅过程,每秒 emit.next 一个随机数,并在清理函数中 clearInterval 停止定时器——这演示了订阅生命周期中服务端资源释放的标准写法。
api 路由:query 与输入回退
api.ts 提供 version 和 hello 两个查询,其中 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 手动执行,等价流程如下:
-
克隆主仓库并安装依赖(原 README 使用的命令):
git clone git@github.com:trpc/trpc.git cd ./trpc yarn yarn dev -
安装示例依赖并以开发模式运行(
dev脚本通过npm-run-all并行启动服务端与客户端,客户端用wait-port 2022等待服务就绪):cd ./examples/fastify-server yarn yarn dev -
也可以先构建产物再从全新构建启动(
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-dev 用 start-server-and-test 对 http-get://localhost:2022 做健康检查后才执行客户端,这与服务端 server.ts 中注册的 GET / 返回 { hello: 'wait-on 💨' } 的兜底路由相配合。
小结
这个示例是理解 tRPC Fastify 适配器的一条最短完整路径:服务端用 fastifyTRPCPlugin 一个插件同时注册 HTTP 通配路由与 WebSocket 订阅端点,客户端用 splitLink 把订阅与常规调用分流到 wsLink 和 httpBatchLink,两端通过共享的 AppRouter 类型与 superjson transformer 保持类型和序列化一致。若需要扩展自己的服务,建议从 config.ts 的参数入手调整前缀与端口,再按 routers 目录 中 query/mutation/subscription 的既有模式添加自己的过程;更底层的插件行为可以参考 fastifyTRPCPlugin.ts 与 fastifyRequestHandler.ts 的实现。
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 StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00