tRPC 在 Bun 运行时中的端到端实践:从 fetch 适配器到 Vanilla Client
本文基于 tRPC 仓库中的 Bun 官方示例(examples/bun),演示如何在 Bun 运行时中搭建一套端到端类型安全的 API:服务端用 @trpc/server/adapters/fetch 的 fetchRequestHandler 挂到 Bun.serve 上,客户端用 createTRPCClient + httpBatchLink 发起带批量能力的请求。读完本文,你将掌握 Bun 场景下 tRPC 的完整落地方式:Router 定义、Context 注入、批量链接的工作原理,以及仓库配套的构建、开发与自动化测试脚本。
示例项目结构与工作区归属
Bun 示例位于仓库的 examples/bun/ 目录,是一个被 pnpm workspace 直接纳入的独立包——pnpm-workspace.yaml 中通过 'examples/*' 规则将其识别为 workspace 成员:
packages:
- 'packages/*'
- 'examples/*'
...
示例的入口文件与脚本定义如下(package.json):
{
"name": "examples-bun",
"private": true,
"scripts": {
"build": "bun build src/index.ts --outdir ./dist && bun build src/client.ts --outdir ./dist",
"dev:server": "bun run src/index.ts --watch",
"dev:client": "wait-port 3000 && bun run src/client.ts --watch",
"start:server": "bun run dist/index.js",
"start:client": "wait-port 3000 && bun run dist/client.js",
"test-dev": "start-server-and-test 'bun run src/index.ts' 3000 'bun run src/client.ts'",
"test-start": "start-server-and-test 'bun run dist/index.js' 3000 'bun run dist/client.js'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@trpc/client": "npm:@trpc/client",
"@trpc/server": "npm:@trpc/server"
},
"devDependencies": {
"@types/bun": "^1.1.12",
"eslint": "^9.26.0",
"start-server-and-test": "^1.12.0",
"typescript": "^5.9.2",
"wait-port": "^1.0.1"
}
}
有两个细节值得注意:
- 依赖声明方式:
@trpc/client与@trpc/server均写作"npm:@trpc/..."形式。这是因为该示例位于 pnpm workspace 内,这种写法明确声明“以 npm 包形式解析”(与本地 workspace 包区分),保证示例拿到的是发布产物而非本地源码; - 脚本分层:
dev:*系列跑 TS 源码并开启--watch热重载,start:*系列跑bun build编译后的dist/产物。dev:client与start:client前缀了wait-port 3000,即先阻塞等待 3000 端口就绪再启动客户端,避免客户端连接时服务端尚未监听。
服务端:Bun.serve 挂载 fetch 适配器
Bun 的 HTTP 服务 API 是 Bun.serve,其签名与 Web fetch API 高度一致(接收一个 fetch(request) 回调),这正好契合 tRPC v11 对 fetch 适配器的设计。示例服务端入口 src/index.ts 全文如下:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from './router';
Bun.serve({
port: 3000,
fetch(request) {
// Only used for start-server-and-test package that
// expects a 200 OK to start testing the server
if (request.method === 'HEAD') {
return new Response();
}
if (new URL(request.url).pathname === '/') {
return new Response('hello world');
}
return fetchRequestHandler({
endpoint: '/trpc',
req: request,
router: appRouter,
createContext: () => ({}),
});
},
});
逐段拆解其职责:
- 端口与路由分发:服务监听
3000端口;/路径返回hello world作为健康探测;其余请求统一交给fetchRequestHandler处理; endpoint: '/trpc':声明 tRPC 程序挂载在 URL 前缀/trpc之下,客户端后续就会请求http://127.0.0.1:3000/trpc/hello这类地址;createContext: () => ({}):这是请求级上下文的注入点。此处示例返回空对象;在实际应用中,你可以在这里从req(fetch 的 Request 对象)中解析出 session、用户身份等信息。tRPC 的createContext会在每个请求执行时调用,其返回值成为所有 procedure 中间件与处理函数的ctx参数;HEAD请求的特殊处理:源码注释说明这是为start-server-and-test包准备的——该包在跑测试前会用 HEAD 请求轮询端口,期待一个200 OK作为“服务就绪”信号。若不拦截,HEAD 请求会落入fetchRequestHandler并因路径不匹配返回 404,导致测试等待超时。
底层原理:fetchRequestHandler 如何解析 URL
fetchRequestHandler 的实现位于 fetchRequestHandler.ts。其中 URL 拆解逻辑揭示了 endpoint 参数如何工作:
const url = new URL(opts.req.url);
const pathname = trimSlashes(url.pathname);
const endpoint = trimSlashes(opts.endpoint);
const path = trimSlashes(pathname.slice(endpoint.length));
即:从请求的完整 pathname 中裁剪掉 endpoint 前缀(/trpc),剩余部分(如 hello、post.createPost)作为 tRPC 的 procedure 路径传给核心的 resolveResponse 做路由分发。此外该文件还处理了 responseMeta 回调——允许 Router 侧通过 ctx.meta 之类的机制自定义响应状态码与响应头,并统一写入 Headers 对象后随 Response 返回。也就是说,无论底层运行时是 Node 的 http 模块还是 Bun 的 Bun.serve,只要它能以 fetch 风格的 Request/Response 与 tRPC 对话,就能复用同一套处理逻辑。
Router 定义:Zod 校验与端到端类型
示例的 src/router.ts 定义了三层结构:
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
let id = 0;
const db = {
posts: [{ id: ++id, title: 'hello' }],
};
const t = initTRPC.create();
const publicProcedure = t.procedure;
const router = t.router;
const postRouter = router({
createPost: publicProcedure
.input(z.object({ title: z.string() }))
.mutation(({ input }) => {
const post = { id: ++id, ...input };
db.posts.push(post);
return post;
}),
listPosts: publicProcedure.query(() => db.posts),
});
export const appRouter = router({
post: postRouter,
hello: publicProcedure.input(z.string().nullish()).query(({ input }) => {
return `hello ${input ?? 'world'}`;
}),
});
export type AppRouter = typeof appRouter;
要点:
initTRPC.create():创建 tRPC 实例,派生出procedure(procedure 基类)与router(路由构造器)。未配置 transformer(如 superjson)时,输入输出均为 JSON 序列化;post子路由:演示了 Router 的嵌套能力,post.createPost(mutation,接收{ title: string }并返回新建的 post)与post.listPosts(query,返回整个 posts 数组);helloprocedure:输入z.string().nullish()表示可省略、可为null或字符串,处理函数内用input ?? 'world'兜底,正好覆盖“带参/不带参”两种调用形态;AppRouter类型导出:这是端到端类型安全的枢纽。客户端createTRPCClient<AppRouter>()会把整个 Router 树的 procedure 签名(输入类型、返回类型)推导到代理对象的每个方法上,参数写错在编译期即报错,无需任何代码生成。内存中的db对象模拟数据库,++id计数器保证 post 自增主键。
客户端:Vanilla tRPC Client 与批量链接
Vanilla 客户端不依赖任何 UI 框架(React Query 等),适合 Node/Bun 脚本、服务端编排、CLI 等场景。示例的 src/client.ts 完整演示了典型用法:
import { createTRPCClient, httpBatchLink, loggerLink } from '@trpc/client';
import type { AppRouter } from './router.ts';
const url = 'http://127.0.0.1:3000/trpc';
const proxy = createTRPCClient<AppRouter>({
links: [loggerLink(), httpBatchLink({ url })],
});
loggerLink()在前:link 以数组形式串联,loggerLink作为第一个 link 拦截后续所有操作并打印请求/响应日志,方便观察批量的实际 HTTP 流量;httpBatchLink({ url }):终端 link,负责真正发请求。其实现位于 httpBatchLink.ts,核心机制是:- 收集时间窗口内同类型的多个操作,把 path 用逗号拼接成一次 URL(如
/trpc/hello,hello),input 序列化为 JSON 数组,一次 HTTP 请求拿回全部结果(batchOps.map((op) => op.path).join(',')); - 支持
maxURLLength与maxItems两个可选参数(默认均为Infinity):当拼接后的 URL 超长或操作数过多时,validate判定批次不成立,自动回退为逐个请求,防止超过 HTTP 服务器的 URL 长度限制; - 客户端示例中
await Promise.all([proxy.hello.query(), proxy.hello.query('client')])并发发起两个hello查询,得益于批处理,二者实际上会被合并为一次 HTTP 请求——这正是 README 中特意安排“parallel queries”这一步的意图;
- 收集时间窗口内同类型的多个操作,把 path 用逗号拼接成一次 URL(如
- 类型化调用:
proxy.hello.query()/proxy.hello.query('client')的入参与返回值类型均由AppRouter推导,proxy.post.createPost.mutate({ title })的入参必须满足z.object({ title: z.string() })的推断类型; - 客户端末尾打印
should be a clean exit if everything is working right,并在main().catch中process.exit(1),使该脚本可直接作为端到端冒烟测试的判定依据(退出码 0/1)。
注意 Bun 环境下 import type { AppRouter } from './router.ts' 显式携带 .ts 扩展名,对应 tsconfig.json 中开启的 "allowImportingTsExtensions": true。
TypeScript 配置:面向 Bun 的编译选项
示例的 tsconfig.json 是一组典型的“Bun + 现代 ES”配置:
{
"compilerOptions": {
"lib": ["esnext"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler", // TS 5.x+
"noEmit": true,
"allowImportingTsExtensions": true,
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
"esModuleInterop": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
}
}
其中 moduleResolution: "bundler" 允许以打包器/Bun 的解析规则处理模块(裸导入、bun:* 内建模块等),配合 @types/bun 提供 Bun.serve 等全局 API 的类型声明;typecheck 脚本仅做 tsc --noEmit 校验,实际编译由 bun build 完成——这是 Bun 生态的常见分工:tsc 负责类型检查,Bun 的打包器负责产物生成。
运行与验证方式
按 examples/bun/README.md 的指引,前提是先安装 Bun(仓库示例假定本机已具备 Bun 运行时)。在仓库根目录的 workspace 中安装依赖后:
# 终端一:启动服务端(监听 3000 端口,--watch 热重载)
bun dev:server
# 终端二:等待端口就绪后运行客户端
bun dev:client
客户端成功执行会依次打印:两次并发 hello 查询的日志、created post hello client、has posts [...] first: hello,最后输出干净退出的确认信息。
除手工运行外,示例还提供两条自动化验证路径,均基于 start-server-and-test(这也是服务端要拦截 HEAD 请求的原因):
| 脚本 | 流程 |
|---|---|
test-dev |
直接跑 TS 源码:start-server-and-test 'bun run src/index.ts' 3000 'bun run src/client.ts' |
test-start |
先 bun build 再验证产物:start-server-and-test 'bun run dist/index.js' 3000 'bun run dist/client.js' |
即:启动服务端 → 轮询 3000 端口直到就绪 → 运行客户端脚本 → 根据客户端退出码判定成败。这使得该示例既可以作为开发演示,也可以作为 CI 中的一条最小端到端测试链路。
小结
Bun 示例用不到百行代码展示了 tRPC 在“非 Node 框架”环境下的接入范式:只要运行时提供 fetch 风格的请求/响应对象(Bun.serve 的 fetch 回调即是),就能通过 fetchRequestHandler 一行接入任意 tRPC Router;客户端侧则以 createTRPCClient + links(loggerLink 观测、httpBatchLink 批处理)完成类型安全的调用。与仓库中 express、fastify、lambda 等其他示例相比,Bun 路径的最大差异仅在于宿主 HTTP 服务由 Bun.serve 承担,其余 Router 定义、类型推导与链接机制完全一致——这也从侧面印证了 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 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