服务端函数
从页面到数据库的唯一数据通道:loader、React Query、authedFn 与 adminFn,何时改用 API 路由,以及限流。
ShipKit 里每个页面读写数据的方式都一样。完整跟过一个例子,其余的就都看得懂;新页面基本就是照着现有的抄一份。
路由 loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1
一次读取,从头到尾
积分页是个好例子。先看 server function,在 src/features/credits/server/fns.ts:
import { authedFn } from '@/core/server/fn'
export const getMyCreditsFn = authedFn.handler(async ({ context }) => {
const { total: _total, ...rest } = await myCredits(context.session.user.id)
return rest
})
处理函数运行前,authedFn 已经检查过会话:没有会话就跳转到 /login;有的话,context.session 里就是当前登录用户。
接着,feature 的 queries.ts 给这个函数配上缓存键:
export const myCreditsQuery = queryOptions({
queryKey: ['credits'],
queryFn: () => getMyCreditsFn(),
})
路由的 loader 在页面渲染前预热缓存(src/routes/_app/credits.tsx):
export const Route = createFileRoute('/_app/credits')({
loader: ({ context }) => context.queryClient.ensureQueryData(myCreditsQuery),
component: CreditsPage,
})
组件再用 useQuery(myCreditsQuery) 或 useSuspenseQuery(myCreditsQuery) 读同一个 query。数据已经在缓存里,不会重复加载。直接打开页面时,loader 在服务端运行,结果随页面一起下发;在应用内跳转时,同一个函数通过网络调用。加载到的数据在 30 秒内视为新鲜(src/integrations/tanstack-query/root-provider.tsx 里的 staleTime),鼠标悬停在链接上时就会预加载对应路由。
写操作与输入校验
写操作就是带校验器的 server function,由 useMutation 调用,成功后让改动涉及的 query 失效:
export const adjustCreditsAdminFn = adminFn
.validator(
z.object({
userId: z.string().min(1).max(100),
amount: z.number().int().min(-1_000_000).max(1_000_000),
reason: z.string().trim().min(1).max(200),
}),
)
.handler(async ({ data, context }) => {
await assertPermission(context.session, 'credits.write')
// ……写入积分流水、记审计、通知用户
})
const adjust = useMutation({
mutationFn: () => adjustCreditsAdminFn({ data: { userId, amount, reason } }),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['admin', 'users', userId, 'credits'],
})
},
})
校验器是一个 zod schema。处理函数拿到的 data 已经有类型、也已校验过,处理函数自己不需要再解析输入。
authedFn 与 adminFn
两者都在 src/core/server/fn.ts,是写 server function 仅有的两个起点。
| 起点 | 检查内容 | 适用场景 |
|---|---|---|
authedFn | 已登录的会话,否则跳转 /login | 用户对自己账户做的一切 |
adminFn | 上面那项,再加 admin.access 权限,否则跳转 /dashboard | 管理后台里的一切 |
adminFn 只回答“这个人能不能打开后台”。每个后台函数还必须像上面那样,用 assertPermission(context.session, '<permission>') 写明自己需要的具体权限。单元测试 src/core/authz/registry.test.ts 会对漏写的后台函数报错。角色与权限见权限。
不要在处理函数里手动检查会话,也不要把 createServerFn 包进自己写的工厂函数(比如一个 permissionFn('credits.write'))。构建看不穿这层调用,最后会把整个模块连同数据库代码一起打进浏览器。
两者都是 POST 函数。GET 类型的 server function 用一个普通链接就能触发,而会话 cookie 在从别的网站跳转过来的顶层导航中也会被带上,恶意页面就能把已登录用户直接“链接”进一次写操作。此外,src/server.ts 会拒绝所有 Sec-Fetch-Site(或 Origin)头表明来自其他站点的 /_serverFn/* 调用。
别让服务端代码进浏览器
feature 的 server/fns.ts 会被客户端代码导入(经由 queries.ts),所以构建会把每个 server function 变成一个小小的客户端存根,并剥离处理函数用到的代码。处理函数之外的东西它剥不掉。落到实践上:
- 数据库辅助函数放在单独的服务端模块里(credits 有
server/credits.ts、server/me.ts、server/admin.ts),在处理函数内部调用。 - 不要往
src/core/server/fn.ts里加任何访问数据库的普通辅助函数。这个文件会被打进浏览器,assertPermission放在src/core/authz/assert.ts正是这个原因。 - 界面和服务端共用的常量放在 feature 的
config.ts,不要放schema.ts。
打包规则的更多细节见架构。
返回键,而不是翻译好的文字
server function 的地址是 /_serverFn/...,URL 里没有语言信息。在它内部,Paraglide 只能退回到 cookie 或 Accept-Language 头,而不是用户正在看的页面的语言。一个通过链接进入 /zh 的访客,就会收到英文文案。
所以 server function 只返回数据和机器可读的原因,由组件挑选文案。推广员申请提现就是个好例子:
// 服务端:src/features/affiliate/server/fns.ts
if (amount < minimum) {
return { ok: false as const, reason: 'below_minimum' as const, minimum }
}
// 客户端:src/routes/_app/affiliate.tsx
toast.error(
result.reason === 'below_minimum'
? m.affiliate_payout_below_minimum({ amount: money(result.minimum ?? minimum) })
: m.affiliate_payout_open_request(),
)
例外是发到应用之外的文字:邮件和站内通知按收件人保存的 user.locale 本地化,从不看请求。见多语言和邮件与通知。
什么时候该用 API 路由
API 路由是 src/routes/api/ 下带 server.handlers、没有组件的文件,服务的是不属于你自己页面的调用方:
| 路由 | 为什么不用 server function |
|---|---|
api/auth.$.ts | better-auth 的接口和 OAuth 回调 |
api/webhooks.stripe.ts、api/webhooks.creem.ts | 支付服务商签名后的回调 |
api/files.ts、api/files.$fileId.ts、api/avatar*.ts、api/feedback-image*.ts | multipart 上传和流式下载 |
api/v1/admin/*、api/v1/me/* | 对外 API,用 API key 认证 |
api/ph/$.ts | 产品分析的代理 |
如果是你自己的页面要数据,就写 server function。对外 API 有自己的包装函数(src/core/server/api.ts 里的 adminApi、userApi、userDoc、userAction),负责校验 key 和统一的 JSON 格式;见 API key。
限流
src/core/server/rate-limit.ts 里的 isRateLimited 基于 Cloudflare 的限流 binding 计数,这些 binding 声明在 wrangler.jsonc 的 ratelimits 下:
| Binding | 限额 | 用途 |
|---|---|---|
PUBLIC_RATE_LIMIT | 每分钟 5 次 | 真正消耗资源的写操作:上传、请求登录验证码 |
ANALYTICS_RATE_LIMIT | 每分钟 200 次 | 分析代理,浏览器每个页面会调用好几次 |
if (await isRateLimited('files-upload', { headers: request.headers, key: session.user.id })) {
return tooManyRequests()
}
第一个参数是计数桶的名字。默认按客户端 IP 计数;传入 key 可以改成按用户或按邮箱地址计数。在 server function 里可以省略 headers,因为请求上下文是隐式可得的。如果 binding 不存在,检查直接放行,配置上的疏漏不会让接口挂掉。
大多数读取用不着它:读取很便宜,而且本来就绑定在会话上;better-auth 和 API key 插件也各自对自己的接口做了限流。需要它的地方是请求有实际成本(写 R2、调外部服务),或者前面没有任何会话把关的时候。