架构
一个请求在 ShipKit 里怎么走,代码如何分成 core 和按功能切分的 feature,以及让网站保持轻快的打包规则。
ShipKit 就是一个 Cloudflare Worker。营销网站、登录后的应用、管理后台、webhook、对外 API 都由同一个 Worker 提供,每晚的定时任务也在里面跑。这一页是张地图:先跑什么、代码放在哪、以及让各部分互不干扰的几条规则。
请求路径
每个请求都依次经过三个文件。
| 文件 | 作用 |
|---|---|
src/server.ts | Worker 入口。拒绝跨站调用 server function;用 paraglideMiddleware 包住 TanStack Start 的处理器,让服务端代码知道当前请求的语言;给每个响应加上安全响应头;导出供定时任务使用的 scheduled。 |
src/start.ts | TanStack 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。