服务端函数

从页面到数据库的唯一数据通道: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.$.tsbetter-auth 的接口和 OAuth 回调
api/webhooks.stripe.ts、api/webhooks.creem.ts支付服务商签名后的回调
api/files.ts、api/files.$fileId.ts、api/avatar*.ts、api/feedback-image*.tsmultipart 上传和流式下载
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、调外部服务),或者前面没有任何会话把关的时候。