架构

一个请求在 ShipKit 里怎么走,代码如何分成 core 和按功能切分的 feature,以及让网站保持轻快的打包规则。

ShipKit 就是一个 Cloudflare Worker。营销网站、登录后的应用、管理后台、webhook、对外 API 都由同一个 Worker 提供,每晚的定时任务也在里面跑。这一页是张地图:先跑什么、代码放在哪、以及让各部分互不干扰的几条规则。

请求路径

每个请求都依次经过三个文件。

文件作用
src/server.tsWorker 入口。拒绝跨站调用 server function;用 paraglideMiddleware 包住 TanStack Start 的处理器,让服务端代码知道当前请求的语言;给每个响应加上安全响应头;导出供定时任务使用的 scheduled。
src/start.tsTanStack Start 的全局配置(defaultSsr: true),全局的请求或函数中间件也注册在这里。
src/router.tsx创建路由并把 React Query 接入 SSR。其中的 rewrite 在进来时去掉 URL 里的语言前缀、出去时再加回来,所以 /zh/pricing 和 /pricing 命中同一个路由文件。

有了这层改写,路由文件永远不用处理语言片段。英文不带前缀,中文在 /zh 下,详见多语言。

src/server.ts 还会在文件顶部导入设置、权限和定时任务三个注册表。这样不管 Worker 是因为页面请求、webhook 还是定时任务被唤醒,这些注册表都在被用到之前就已就绪。

路由分组

路由就是 src/routes/ 下的文件。以下划线开头的目录是布局分组:共享布局和守卫,但不会出现在 URL 里。

分组URL渲染方式
_marketing//、/pricing、/blog、/docs、/legal/*服务端渲染,并在构建时预渲染成静态 HTML(见 vite.config.ts 里的 prerender)
_auth//login服务端渲染
_app//dashboard、/billing、/credits、/files、/settings、/api-keys、/affiliate、/admin/*ssr: 'data-only':loader 在服务端运行,页面在浏览器里渲染
api//api/*只有服务端路由,没有界面

src/routes/_app.tsx 这个外壳就是会话守卫:它的 beforeLoad 读取会话,没有就跳转到 /login。用户自己的页面和管理后台共用这个外壳,由侧边栏在两者之间切换。后台的细节见管理后台。

还有几个路由不在分组里:/r/$code(推广链接,一个设置 cookie 后跳转的服务端路由)、/waitlist,以及只在开发环境可用的 /blocks,用来预览落地页的所有区块。

core 与 feature

src/ 下的代码分成共享内核和一组纵向切片。

src/core/ 是所有功能都依赖的部分:数据库客户端(db/)、服务端基础设施(server/:binding、server function 中间件、对外 API 的辅助函数、限流、安全响应头)、支付事件层、权限(authz/)、运营设置、邮件发送,以及下文要讲的一组注册表。core 并不知道有哪些可选功能存在。

src/features/<name>/ 是一个完整的产品能力,从头到尾自成一体。一个 feature 拥有自己的表、服务端代码和界面:

src/features/credits/
  schema.ts          Drizzle 表定义
  queries.ts         路由和组件用的 React Query 配置
  permissions.ts     它声明的后台权限
  settings.ts        它注册的可由运营修改的设置
  server/            server function、事件处理、定时任务、API 查询
  components/        它的界面

自带的 feature 有 auth、billing、credits、files、content、audit、email-log、feedback、notifications、waitlist、analytics、affiliate 和 demo。feature 之间互不导入,需要共享的东西都经过 core/。正因为如此,大多数 feature 几分钟就能删掉:见删除功能;按同样方式新建一个,见添加功能。

src/config/ 放的是把模板变成你自己产品时要改的东西:品牌、套餐、落地页、主题。见配置。

注册表:core 如何调用 feature 而不导入它

core 经常需要触达 feature。一笔付款进来,billing、credits、affiliate 都要各自处理;每晚的定时任务要跑所有 feature 的维护工作。但 core 不能导入这些 feature,否则删掉一个 feature,core 就坏了。

src/core/ 里到处用的是同一个小模式:

  • events.ts 持有一个注册表和往里添加的函数,比如 onPaymentEvent(handler)、registerJob(job)、onAudit(sink)。
  • 每个 feature 在自己的文件里完成注册,例如 src/features/credits/server/events.ts 调用 onPaymentEvent(...)。
  • core 目录下的 handlers.ts 为每个 feature 写一行导入,由这行导入触发注册。
// src/core/jobs/handlers.ts
import '@/core/payment/jobs'
import '@/features/auth/server/jobs'
import '@/features/billing/server/jobs'
import '@/features/credits/server/jobs'
// ...

删除一个 feature 就是删掉它那一行,注册表里少一项而已。支付事件、定时任务、后台概览统计、账户删除与导出、审计、邮件投递记录、站内通知、产品分析、运营设置、权限,以及后台用户详情页的各个分区,都是这个结构。

有些注册表会自己加载。audit()、notify()、track() 在很多地方被调用,所以它们在第一次调用时自行导入各自的 handlers.ts;调用方从不需要导入它,feature 被删掉后调用就什么也不做。

打包规则

有两个构建层面的事实决定了这里的代码写法。

路由里只有 component 会被代码分割。 路由的 loader、validateSearch 等其他选项,以及路由文件顶部的所有导入,都会进入主包,也就是落地页要下载的那一份。所以需要注册表的路由在 loader 内部导入它:

loader: async ({ context }) => {
  await import('@/core/authz/handlers')
  return context.queryClient.ensureQueryData(adminRolesQuery)
},

有些看起来是服务端的文件,其实也会被打进浏览器端。 feature 的 server/fns.ts 就是一例:server function 会在客户端留下一个存根,输入校验器也留在客户端包里。构建只会剥离 server function 处理函数内部用到的东西。由此有三条规则:

  • cloudflare:workers 只能在 src/core/server/cf.ts 这一个文件里导入,其他地方一律通过 getBindings() 拿 binding。在别处导入会弄坏客户端包。
  • 客户端需要的常量(状态值、默认值)放在 feature 的 config.ts 里,这个文件不导入任何只属于服务端的东西;不要放在 schema.ts 里。
  • feature 的 settings.ts 和 permissions.ts 也会被浏览器加载,必须保持客户端安全。设置这部分由测试 src/core/settings/handlers.test.ts 把关。

这些规则也写进了 CLAUDE.md,AI agent 在仓库里工作时会遵守;见 AI 协作。

数据通道一览

页面读写数据只有一条路:

路由 loader -> queryClient.ensureQueryData(query) -> server function -> Drizzle -> D1

loader 预热 React Query 缓存,组件读取同一个 query;写操作调用 server function,然后让受影响的 query 失效。server function 都从 authedFn 或 adminFn 开始,会话和访问检查由它们代劳。API 路由只留给不是你自己页面的调用方:登录回调、支付 webhook、文件流和对外 API。

这条通道的细节见 服务端函数,D1 与 Drizzle 见数据库,谁能调用什么见权限。