运维

生产环境里除了请求之外还在运行的东西:每晚的定时任务、日志与错误页、限流、安全响应头、CI 和备份。

这一页讲 ShipKit 上线之后靠什么保持健康:定时维护、错误在哪里看、公开接口有哪些保护,以及 CI 在发布前检查什么。

定时任务

wrangler.jsonc 里只有一个 cron 触发器,每天 00:00 UTC 触发("crons": ["0 0 * * *"])。Worker 在 src/server.ts 里的 scheduled处理函数会调用 runJobs(),依次执行所有通过 src/core/jobs/events.ts 的registerJob 注册的任务。每个功能在 src/core/jobs/handlers.ts 里用一行import 注册自己的任务,所以删掉一个功能,它的任务也随之消失。

任务逐个执行。某个任务抛错只会被记进日志,不影响其余任务。每个任务都必须是幂等的,因为 cron 可能触发两次,你也可以手动触发。

任务作用
payment.prune-webhooks删除超过保留期(默认 90 天)的 webhook 原始数据
auth.purge-expired删除过期的会话、验证记录和过期的 API key,better-auth 自己从不清理它们
auth.remind-deleted-accounts在已关闭的账号被彻底抹除前 3 天发一封提醒邮件,只发一次
auth.purge-deleted-accounts抹除已过保留期(默认 30 天)的已关闭账号,每晚最多 25 个
billing.expire-stale-subscriptions付费周期结束超过宽限期(默认 3 天)仍没收到 webhook 的订阅,标记为过期。这是漏收事件时的兜底
affiliate.mature-commissions退款保留期过后,把待结算的佣金标记为可提现
credits.monthly-grant发放每个用户的月度额度,每个自然月只发一次
audit.prune删除超过保留期(默认 180 天)的审计日志
email_log.prune删除超过保留期(默认 90 天)的邮件日志
demo.purge-visitors抹除一天前创建的公开演示账号。没开过演示模式时什么也不会匹配

这些保留期和宽限期都是运营设置,在后台 → 设置里改,不需要重新部署(见配置)。

执行时间对其中一个任务很关键:上个月的积分额度恰好在 00:00 UTC 过期,credits.monthly-grant 在同一时刻运行,用户余额才不会有几个小时显示为零。如果要改 cron 时间,记得考虑这一点。

在 bun run dev 运行时,本地手动触发全部任务:

curl "localhost:3000/cdn-cgi/handler/scheduled"

新增任务的方法见添加功能:在功能的server/jobs.ts 里注册,再在 src/core/jobs/handlers.ts 里加一行 import。

日志与错误

wrangler.jsonc 开启了 Workers Logs("observability": { "enabled": true }),采样率 100%。请求日志、异常以及所有 console.* 输出都会出现在 Cloudflare控制台对应 Worker 的页面里,不需要写任何代码。流量非常大时可以调低head_sampling_rate。想在终端实时查看日志:

bunx wrangler tail

几个值得搜索的关键字:

  • [jobs] <name>: …:每个任务的结果(比如 pruned=12)或失败信息
  • stripe webhook <type> <id> failed / creem webhook … failed:支付事件的处理函数抛错了;事件已被释放,服务商重试时会完整再处理一次
  • [email] RESEND_API_KEY not set:有一封邮件被跳过

用户永远看不到堆栈。src/components/error-pages.tsx 提供 404 和 500 页面,作为路由器默认的 not-found 和 error 组件;生产环境只显示通用文案,细节进日志。

后台还从业务层面记录发生了什么:审计日志、记录每次发信结果的邮件日志,以及保存下来的 webhook 原始数据。见管理后台。

限流

wrangler.jsonc 声明了两个 Cloudflare 限流 binding,默认都按客户端 IP 计数,除非另外指定 key:

Binding限额用在哪里
PUBLIC_RATE_LIMIT每分钟 5 次请求登录验证码(按 IP 和按收件地址各计一次)、上传头像、上传文件(按用户)、反馈截图、演示站登录
ANALYTICS_RATE_LIMIT每分钟 200 次/api/ph/* 这个 PostHog 同源代理,每次页面浏览都会发好几个请求

新增的公开接口只要会消耗实际资源,就调用 src/core/server/rate-limit.ts 的isRateLimited(scope, options),超限时返回 tooManyRequests()(429,带retry-after: 60)。传入 key 可以不按 IP 计数,比如登录验证码就按收件地址计数。binding 缺失时检查直接放行,配置上的疏漏不会让接口整个不可用。另外,API key 还有 better-auth 自带的每分钟 300 次限制。

安全响应头

每个响应都带有 X-Content-Type-Options、X-Frame-Options: DENY、Referrer-Policy、Permissions-Policy、Cross-Origin-Opener-Policy,HTTPS下还有 Strict-Transport-Security。它们来自两处,两份列表必须保持一致:

  • src/core/server/security-headers.ts:Worker 处理的所有响应,在src/server.ts 里统一加上
  • public/_headers:预渲染页面和静态资源,这些由 Cloudflare 直接返回,不经过 Worker

e2e/security-headers.spec.ts 会检查页面、重定向和 API 响应。

默认没有 Content-Security-Policy。TanStack Start 靠内联脚本完成 hydration,有用的 CSP 需要每个请求生成 nonce;等你具备这个条件再加。opener 策略用的是same-origin-allow-popups 而不是 same-origin,因为 Google One Tap 的弹窗需要保留 window.opener。

server function 还多一道防护:浏览器标记为跨站的 /_serverFn/… 请求,会在src/server.ts 里被直接以 403 拒绝,根本不会被解析。API 路由不受此限制,因为 webhook 和使用 API key 的客户端本来就来自外部。

CI

.github/workflows/ci.yml 在每次推送到 main、每个 pull request,以及手动触发(workflow_dispatch)时运行:

Job何时运行内容
Typecheck · lint · test · build每次build、typecheck、check、test,然后 db:generate 必须什么都不写;改了 schema 却没带迁移会在这里失败
End-to-end每次在开发服务器上跑 Playwright 测试
Verify deletionPull request按 scripts/verify-deletion.ts 里的方案,每个方案一个并行 job
Deploy推送到 main 或手动触发,且前两个 job 通过bun run deploy

CI 用 .dev.vars.example 生成的占位值构建,真实密钥从不进入 CI。Worker 运行时读取的是用 wrangler secret put 写入的真实 secret。

部署 job 需要仓库 production environment 里配置 CLOUDFLARE_API_TOKEN 和CLOUDFLARE_ACCOUNT_ID,没有就跳过并明确提示。它从不执行迁移,只检查生产 D1是否还有待执行的迁移,有就拒绝部署。所以顺序永远是:先在本机跑bun run db:migrate:remote,再推送(或重新运行 workflow)。见部署。

备份

ShipKit 不带备份任务。数据存放在两个 Cloudflare 服务里:

  • D1 保存数据库的时间点历史(Time Travel),用 wrangler d1 time-travel管理,能回溯多久取决于你的 Cloudflare 套餐。想自己留一份副本,可以用wrangler d1 export DB --remote --output backup.sql 把整个库导出为 SQL。
  • R2 存放上传的文件和头像,没有自动副本;需要的话在 Cloudflare 那边自行配置。

执行有风险的迁移前,先导出一份。迁移之所以要手动执行,正是因为 schema 变更应该是一个你亲眼看着完成的步骤。