常见问题
使用 ShipKit 最可能遇到的故障:环境变量校验、迁移、过期的生成文件、OAuth、webhook,以及各自的解决办法。
每一条都写明你看到的现象、原因和解决办法。大部分问题归结为三件事:环境变量只在启动时读取一次,迁移需要手动执行,代码跑在 workerd 而不是 Node 里。
安装与本地开发
启动时报 "Missing or invalid environment secrets"
src/core/env.ts 在第一次读取环境变量时用 zod 校验,失败就抛错并列出有问题的key。必填项:APP_URL(合法 URL)、BETTER_AUTH_SECRET(至少 16 个字符)、GOOGLE_CLIENT_ID 和 GOOGLE_CLIENT_SECRET,其余都是可选的。
解决:还没有 .dev.vars 就先 cp .dev.vars.example .dev.vars,填好这四项(secret 用 openssl rand -base64 32 生成),然后重启 bun run dev。暂时没有Google 凭据的话,填任意非空的占位值也能通过校验;Google 按钮用不了,但邮箱验证码登录可以用。见快速开始。
改了 .dev.vars 却没有生效
环境变量在启动时读取,并在整个进程生命周期内缓存。每次修改后都要重启开发服务器。生产环境根本不读 .dev.vars,它的值通过 wrangler secret put 设置。
报 "Secret X is not set"
某个可选模块在真正用到时通过 requireEnv() 读取自己的 key,没读到。本地把key 加进 .dev.vars,生产环境执行 wrangler secret put X。可选的 key 在使用时才检查、而不是启动时检查,是为了保证删掉一个功能不会导致应用起不来。
bun run typecheck 找不到 @/paraglide/messages
src/paraglide/ 由 Vite 插件根据 messages/*.json 生成,不进 git。先跑一次bun run build 或启动 bun run dev,再做类型检查。CI 先构建再做类型检查,就是这个原因。
手动编译消息后,多语言路由坏了
不要自己运行 paraglide-js compile。它会忽略 vite.config.ts 里的配置(APP_LOCALE cookie 名和 URL 策略),生成的输出会让 /zh 路由失效。重启开发服务器即可,Vite 插件会按正确的配置重新生成 src/paraglide/。见国际化。
3000 端口被占用,或第一次启动因缓存失败
之前的开发服务器还在运行:pkill -f "vite dev"。如果第一次启动就因为其他包管理器留下的 Vite 缓存而失败,执行 rm -rf node_modules/.vite 后重试。
wrangler 警告 D1 的 database_id
wrangler.jsonc 里默认是 "database_id": "REPLACE_ME"。本地开发不使用它,所以部署前这个警告可以忽略;执行 wrangler d1 create 会拿到真正的 id。见部署。
数据库
报 "no such table" 或 "no such column"
代码依赖的迁移还没有应用到当前数据库。本地执行 bun run db:migrate:local,生产环境执行 bun run db:migrate:remote。拉取新的 ShipKit 版本后,留意drizzle/ 里有没有新文件(见升级)。
CI 报 "Schema changed without a migration"
你改了某个 schema.ts,但没有生成对应的迁移。CI 会运行 bun run db:generate,只要它写出任何东西就失败。自己运行一次,把 drizzle/(包括 drizzle/meta)一起提交后再推送。
CI 因 "Unapplied D1 migrations" 拒绝部署
这是预期行为:部署 job 从不执行迁移,也不会在生产 D1 缺迁移的情况下发布代码。在本机运行 bun run db:migrate:remote,然后重新运行 workflow(gh workflow run CI,或在 Actions 页面点 Re-run)。部署依赖新 schema 的代码之前,永远先迁移。
db.transaction() 抛错
D1 不支持交互式事务。几条必须一起执行的语句用 db.batch([...]);检查和写入必须原子完成时,用一条带条件的语句。见数据库。
构建与运行时
报 "Cannot read properties of null (reading 'useEffect')"
开发服务器的 bundle 里出现了两份 React,通常是因为 Vite 扫描依赖时src/paraglide/ 还不存在。先跑一次 bun run build,再重启开发服务器。
引入 cloudflare:workers 后开发服务器崩了
cloudflare:workers 只能在 src/core/server/cf.ts 里引入。放在别处会泄漏到客户端 bundle,让 Vite 开发服务器直接崩溃。binding 通过这个文件的getBindings() 读取,环境变量通过 getEnv() 读取。
在 Node 下正常的库,到了生产环境就出错
Worker 跑在开启了 nodejs_compat 的 workerd 里,不是 Node。任何在运行时求值代码的做法(eval、new Function)都被禁止,这也是 MDX 在构建时编译、而不用运行时内容层的原因。优先选择支持 Workers 的库,并用 bun run dev 测试,它跑的就是同一个运行时。
server function 返回 403 "Cross-site request refused"
server function 只接受本站页面发起的调用。浏览器从其他源或兄弟子域名发来的请求会被刻意拒绝。请从自己的页面调用;如果外部客户端需要这些数据,改为提供API 路由(见 API key)。
登录
Google 显示 redirect_uri_mismatch
Google Cloud Console 里的重定向 URI 必须与<APP_URL>/api/auth/callback/google 完全一致:协议、端口都要对,末尾不带斜杠。生产环境要把生产域名和对应的重定向 URI 加到同一个 client 里,并确认 APP_URL这个 secret 是生产地址而不是 localhost。invalid_client 说明 secret 填错了或和 client id 填反了。/google-oauth skill 会带你一步步排查。
Google One Tap 不出现
One Tap 校验的是已获授权的 JavaScript 来源,不是重定向 URI。把来源加上;本地开发要同时加 http://localhost:3000 和 http://localhost。来源的修改可能要几分钟才生效。广告拦截插件可能挡住弹窗;用户关掉一次后,Google 也会在一段时间内不再弹出。
没有 GitHub 登录按钮
只有 GITHUB_CLIENT_ID 和 GITHUB_CLIENT_SECRET 都设置了才会显示。两个都填上,重启,并把 <APP_URL>/api/auth/callback/github 设为 GitHub OAuth app 的回调地址。
收不到登录验证码邮件
没有 RESEND_API_KEY 时不会发任何邮件,验证码会以[auth] no mail provider — code for … 的形式打印在服务器日志里,本地不配邮件服务也能登录靠的就是这个。生产环境要设置这个 key,并确保src/config/app-config.ts 里的 emailFrom 是你在 Resend 验证过的域名下的地址(默认值只是占位)。发送失败会在后台 → 邮件日志里显示为 failed,并附带服务商返回的错误。如果返回 429,说明这个地址或 IP 在一分钟内请求了太多次验证码。
把自己设为管理员后仍看不到后台
在你的用户记录上设置 role='admin'(见快速开始),然后刷新页面。确认命令执行在正确的数据库上:开发用 --local,生产用 --remote。
支付
购买按钮显示 "Coming soon"
当前支付服务商没有配置密钥时,什么都买不了。PAYMENT_PROVIDER 决定新的结账走哪家(不设置时,只要有 STRIPE_SECRET_KEY 就优先用 Stripe),这家的密钥必须填上。改完要重启。见支付。
webhook 返回 401 "Invalid signature"
签名密钥和发送方对不上。
- 本地 Stripe:
STRIPE_WEBHOOK_SECRET必须是bun run stripe:listen打印出的whsec_…。生产环境则必须是控制台里那个 endpoint 的密钥,不能用 CLI 的。 - Creem:密钥必须来自你在 Creem 控制台创建的 webhook。开发服务器前面如果有任何代理(比如 ngrok 隧道),必须原样转发请求体,因为 Creem 签名的是原始字节。
改完任一密钥都要重启。
webhook 返回 500 "Handler failed"
某个功能的处理函数抛错了。该事件会被释放,而不是标记为已处理,所以服务商重试时会完整地再处理一次。原因可以在日志里按 stripe webhook <type> <id> failed(或creem webhook …)查到。
一点购买就结账失败
src/config/plans.ts 里所有的 stripePriceId 和 creemProductId 默认都是REPLACE_ME 占位,服务商不认识这些 id。先建好商品目录(/stripe 或 /creem skill 可以代劳),再把真实 id 填进每个位置。测试模式和正式模式是两套独立的目录,上线时要把所有 id 都换掉。
stripe trigger 产生了一笔没有用户的订单
这是正常的。CLI 的测试数据里没有 metadata.userId,它能验证签名、去重和转换这几步,但不会给任何人发放权益。要测试真实的发放,在界面上用卡号4242 4242 4242 4242 完成一次购买。
运维
后台改了设置却没生效
设置在每个 Worker isolate 里缓存 60 秒,等一分钟即可。