mcpField
S
server-functions

server-functions

github.com/tanstack开发15.3k206 次安装4 天前更新
代码仓库

关于 server-functions

面向 TanStack Start 开发者,介绍通过 createServerFn 实现仅在服务器运行、可从应用各处调用的类型安全 RPC。内容涵盖输入校验、认证边界、错误处理、请求上下文和缓存一致性。

主要功能

  1. 01创建 GET 与 POST 服务器函数
  2. 02从加载器、组件和事件处理器调用
  3. 03验证输入并处理表单数据
  4. 04处理错误、重定向与未找到响应
  5. 05访问请求和响应上下文
  6. 06协调持久化修改与路由缓存

适用场景

  1. 01将数据库访问封装在服务器端函数中
  2. 02验证提交到服务器的用户输入
  3. 03为私有数据操作添加处理器级认证
  4. 04修改数据后刷新路由加载数据

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/tanstack/router --skill server-functions

安装后在 Claude Code 中输入/server-functions确认已加载。

详细介绍

服务端函数

服务端函数是使用 createServerFn 创建的类型安全 RPC。它们仅在服务端运行,但可从任何位置调用——加载器、组件、钩子、事件处理程序或其他服务端函数。

CRITICAL:服务端函数是 API 端点。无论哪个路由渲染了调用它的 UI,都可以单独访问这些端点。任何会访问私有数据的服务端函数,都必须在处理程序内部(或通过中间件)强制执行身份验证。 路由的 beforeLoad 用于改善用户体验,而非作为数据边界。会话/中间件模式请参见 start-core/auth-server-primitives。 CRITICAL:加载器是同构的——会在客户端和服务端运行。数据库查询、文件系统访问和机密 API 密钥必须放在 createServerFn 中,不能直接放在加载器中。 CRITICAL:不要使用 "use server" 指令、getServerSideProps 或任何 Next.js/Remix 服务端模式。TanStack Start 只使用 createServerFn。

基本用法

import { createServerFn } from '@tanstack/react-start'

// GET (default)
const getData = createServerFn().handler(async () => {
  return { message: 'Hello from server!' }
})

// POST
const saveData = createServerFn({ method: 'POST' }).handler(async () => {
  return { success: true }
})

从加载器调用

import { createFileRoute } from '@tanstack/react-router'
import { createServerFn } from '@tanstack/react-start'

const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
  const posts = await db.query('SELECT * FROM posts')
  return { posts }
})

export const Route = createFileRoute('/posts')({
  loader: () => getPosts(),
  component: PostList,
})

function PostList() {
  const { posts } = Route.useLoaderData()
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  )
}

从组件调用

使用 useServerFn 钩子,从事件处理程序中调用服务端函数:

import { useServerFn } from '@tanstack/react-start'

const deletePost = createServerFn({ method: 'POST' })
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    await db.delete('posts').where({ id: data.id })
    return { success: true }
  })

function DeleteButton({ postId }: { postId: string }) {
  const deletePostFn = useServerFn(deletePost)

  return (
    <button onClick={() => deletePostFn({ data: { id: postId } })}>
      Delete
    </button>
  )
}

缓存一致性变更

让读写操作都通过使用同一个权威数据存储的服务端函数执行。等待写入完成,然后使路由缓存失效,以便其加载器读取持久化后的结果:

const listIssues = createServerFn({ method: 'GET' }).handler(() => {
  return db.issues.findMany()
})

const createIssue = createServerFn({ method: 'POST' })
  .validator((data: unknown) => {
    if (
      typeof data !== 'object' ||
      data === null ||
      !('title' in data) ||
      typeof data.title !== 'string' ||
      data.title.trim().length === 0
    ) {
      throw new Error('Title is required')
    }
    return { title: data.title.trim() }
  })
  .handler(async ({ data }) => {
    return db.issues.create({ data })
  })

export const Route = createFileRoute('/issues')({
  loader: () => listIssues(),
  component: IssuesPage,
})

function IssuesPage() {
  const router = useRouter()
  const createIssueFn = useServerFn(createIssue)

  const handleCreate = async (title: string) => {
    await createIssueFn({ data: { title } })
    await router.invalidate({ sync: true })
  }

  // render Route.useLoaderData() and call handleCreate from the form
}

持久化变更后,不要只更新组件本地状态。创建、更新、删除后以及重新加载页面后,都要验证渲染出的列表。

输入验证

基本验证器
const greetUser = createServerFn({ method: 'GET' })
  .validator((data: { name: string }) => data)
  .handler(async ({ data }) => {
    return `Hello, ${data.name}!`
  })

await greetUser({ data: { name: 'John' } })
Zod 验证器
import { z } from 'zod'

const createUser = createServerFn({ method: 'POST' })
  .validator(
    z.object({
      name: z.string().min(1),
      age: z.number().min(0),
    }),
  )
  .handler(async ({ data }) => {
    return `Created user: ${data.name}, age ${data.age}`
  })

输入验证不会验证输出序列化。当响应架构发生变化时,请更新数据库查询字段、服务返回值、服务端函数结果、加载器调用方和 UI。添加一项运行时测试,调用处理程序或 HTTP 边界,并断言返回的负载中包含新增字段。

FormData
const submitForm = createServerFn({ method: 'POST' })
  .validator((data) => {
    if (!(data instanceof FormData)) {
      throw new Error('Expected FormData')
    }
    return {
      name: data.get('name')?.toString() || '',
      email: data.get('email')?.toString() || '',
    }
  })
  .handler(async ({ data }) => {
    return { success: true }
  })

错误处理

错误
const riskyFunction = createServerFn().handler(async () => {
  throw new Error('Something went wrong!')
})

try {
  await riskyFunction()
} catch (error) {
  console.log(error.message) // "Something went wrong!"
}
重定向
import { redirect } from '@tanstack/react-router'

const requireAuth = createServerFn().handler(async () => {
  const user = await getCurrentUser()
  if (!user) {
    throw redirect({ to: '/login' })
  }
  return user
})
未找到
import { notFound } from '@tanstack/react-router'

const getPost = createServerFn()
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    const post = await db.findPost(data.id)
    if (!post) {
      throw notFound()
    }
    return post
  })

服务端上下文工具

在服务端函数处理程序中访问请求/响应详情:

import { createServerFn } from '@tanstack/react-start'
import {
  getRequest,
  getRequestHeader,
  setResponseHeaders,
  setResponseStatus,
} from '@tanstack/react-start/server'

// Public, non-personalized data — safe to cache shared across users.
const getPublicData = createServerFn({ method: 'GET' }).handler(async () => {
  setResponseHeaders({
    // 'public' is correct ONLY when the response does not depend on identity.
    // For anything tied to a session/user/tenant, use 'private' or 'no-store'.
    'Cache-Control': 'public, max-age=300',
  })
  setResponseStatus(200)
  return fetchPublicData()
})

// Authenticated data — must NOT be 'public'.
const getMyData = createServerFn({ method: 'GET' }).handler(async () => {
  const authHeader = getRequestHeader('Authorization')
  // ... auth check ...

  setResponseHeaders({
    // 'private' = only the user-agent may cache. Vary by Cookie/Authorization
    // so any intermediary that does cache keys by identity, not URL alone.
    'Cache-Control': 'private, max-age=60',
    Vary: 'Cookie, Authorization',
  })
  return fetchPersonalizedData()
})

可用工具:

  • getRequest() — 完整的 Request 对象
  • getRequestHeader(name) — 单个请求标头
  • setResponseHeader(name, value) — 单个响应标头
  • setResponseHeaders(headers) — 多个响应标头
  • setResponseStatus(code) — HTTP 状态码

文件组织

src/utils/
├── users.functions.ts   # createServerFn wrappers (safe to import anywhere)
├── users.server.ts      # Server-only helpers (DB queries, internal logic)
└── schemas.ts           # Shared validation schemas (client-safe)
// users.server.ts — server-only helpers
import { db } from '~/db'

export async function findUserById(id: string) {
  return db.query.users.findFirst({ where: eq(users.id, id) })
}
// users.functions.ts — server functions
import { createServerFn } from '@tanstack/react-start'
import { findUserById } from './users.server'

export const getUser = createServerFn({ method: 'GET' })
  .validator((data: { id: string }) => data)
  .handler(async ({ data }) => {
    return findUserById(data.id)
  })

静态导入服务端函数是安全的——构建时会将实现替换为客户端代码包中的 RPC 存根。

常见错误

1. CRITICAL:依赖路由守卫保护服务端函数

beforeLoad 重定向保护的是路由 UI,而不是数据端点。createServerFn 会暴露一个可调用的端点,攻击者可以直接访问它——完全不必加载路由。端点上的身份验证才是安全边界;路由上的身份验证只是为了改善用户体验。

// WRONG — the route guard doesn't reach the handler
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can call the RPC
})
export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
  loader: () => getMyOrders(),
})

// CORRECT — auth enforced on the handler itself
const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })

对每个需要身份验证的 createServerFn 应用 authMiddleware(或等效的处理程序内检查)。完整的会话/中间件模式请参见 start-core/auth-server-primitives,工厂组合方式请参见 start-core/middleware。

2. CRITICAL:在加载器中放入仅限服务端的代码
// WRONG — loader is ISOMORPHIC, runs on BOTH client and server
export const Route = createFileRoute('/posts')({
  loader: async () => {
    const posts = await db.query('SELECT * FROM posts')
    return { posts }
  },
})

// CORRECT — use createServerFn for server-only logic
const getPosts = createServerFn({ method: 'GET' }).handler(async () => {
  const posts = await db.query('SELECT * FROM posts')
  return { posts }
})

export const Route = createFileRoute('/posts')({
  loader: () => getPosts(),
})
3. CRITICAL:使用 Next.js / Remix / React Router DOM 模式

如果文件位于 src/pages/、app/layout.tsx、_app/,或导入了 react-router-dom 或 next/ 中的任何内容,那么它就是使用了错误框架的代码。TanStack Start 使用 src/routes/ + createFileRoute + createServerFn。

// WRONG — "use server" is a React directive, not used in TanStack Start
'use server'
export async function getUser() { ... }

// WRONG — getServerSideProps is Next.js Pages Router
export async function getServerSideProps() { ... }

// WRONG — Next.js App Router server component data fetching
export default async function Page() {
  const data = await fetch(...).then(r => r.json())
  return <div>{data}</div>
}

// WRONG — Remix
export async function loader({ request }) { ... }
export async function action({ request }) { ... }

// WRONG — react-router-dom (a different library)
import { Link, useNavigate } from 'react-router-dom'

// CORRECT — TanStack Start
import { createServerFn } from '@tanstack/react-start'
import { Link, useNavigate, createFileRoute } from '@tanstack/react-router'

const getUser = createServerFn({ method: 'GET' })
  .handler(async () => { ... })

export const Route = createFileRoute('/users/$id')({
  loader: ({ params }) => getUser({ data: { id: params.id } }),
  component: UserPage,
})

如果你在 Agent 输出中看到 src/pages/、app/layout.tsx 或 react-router-dom,说明 Agent 正在为错误的框架生成代码。构建会失败,或者路由会在运行时冲突。

4. HIGH:动态导入服务端函数
// WRONG — can cause bundler issues
const { getUser } = await import('~/utils/users.functions')

// CORRECT — static imports are safe, build handles environment shaking
import { getUser } from '~/utils/users.functions'
5. HIGH:等待服务器函数但未调用

createServerFn 会返回一个函数——必须用 () 调用它:

// WRONG — getItems is a function, not a Promise
const data = await getItems

// CORRECT — call the function
const data = await getItems()

// With validated input
const data = await getItems({ data: { id: '1' } })
6. CRITICAL:使用 Cache-Control: public 缓存经过身份验证的响应

Cache-Control: public, max-age=N 告诉你与用户之间的所有 CDN、代理和共享缓存,这个响应可以提供给任何人。如果响应取决于会话(用户、租户、角色),第一个用户的响应就会被缓存,并重放给下一个用户——导致跨租户数据泄露。

// WRONG — auth'd response, public cache, leaks to next user via CDN
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  const session = await requireSession() // identity-dependent
  setResponseHeaders({ 'Cache-Control': 'public, max-age=300' })
  return db.orders.findMany({ where: { userId: session.userId } })
})

// CORRECT — private + Vary so any cache that does store it keys by identity
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  const session = await requireSession()
  setResponseHeaders({
    'Cache-Control': 'private, max-age=60',
    Vary: 'Cookie, Authorization',
  })
  return db.orders.findMany({ where: { userId: session.userId } })
})

// ALSO CORRECT — opt out entirely for sensitive data
setResponseHeaders({ 'Cache-Control': 'no-store' })

经验法则:如果处理程序读取会话/Cookie/身份验证标头,或根据身份进行分支,该响应就不是 public。默认使用 private(敏感数据则使用 no-store);只有响应内容对所有请求者都逐字节相同时,才使用 public。另请参阅 start-core/deployment,了解完整页面的 ISR/Cache-Control。

7. MEDIUM:何时使用 useServerFn 包装

当服务器函数使用 throw redirect() 或 throw notFound() 时,必须使用 useServerFn——该 Hook 会将抛出的异常接入路由器,让重定向能够实际跳转。对于仅返回数据的服务器函数(直接调用,或通过 useMutation/useQuery 调用),该 Hook 可选。

// Plain data — direct call is fine (also fine to pass to useMutation/useQuery)
<button onClick={() => deletePost({ data: { id } })}>Delete</button>
useMutation({ mutationFn: deletePost })

// Throws redirect/notFound — MUST wrap with useServerFn so the router handles the throw
const signupFn = useServerFn(signup) // signup throws redirect on success
<button onClick={() => signupFn({ data: form })}>Sign up</button>

如有疑问,就使用 useServerFn 包装。对于普通数据函数,它不会产生任何影响;如果函数之后可能添加重定向,它就是安全的默认选择。

8. CRITICAL:在 Loader 中自行请求相对 API URL
// WRONG — this loader also runs during SSR, where a relative URL may fail
export const Route = createFileRoute('/issues')({
  loader: () => fetch('/api/issues').then((response) => response.json()),
})

// CORRECT — call the server function; the client build gets an RPC stub
export const Route = createFileRoute('/issues')({
  loader: () => listIssues(),
})

在仅运行于浏览器的事件处理程序中,使用相对 fetch 没问题。但它并不是通用的 Loader 数据策略。

交叉引用

  • start-core/execution-model — 了解代码的运行位置
  • start-core/middleware — 使用中间件组合服务器函数
  • start-core/auth-server-primitives — 会话、Cookie、OAuth、CSRF、速率限制(身份验证的服务器端部分;getCurrentUser 和外部会话辅助函数应放在此处,而不是模块作用域中)
  • @tanstack/router-core#router-core/auth-and-guards — 路由侧的 UX 守卫;数据身份验证应放在服务器函数、服务器路由或 API 端点处理程序/中间件中

评论

支持 Markdown