/ Cloudflare for SaaS
dual-write to production.
Cloudflare 那邊產品已經 enable 了——但 VibeHost 一行對接 code 都沒有。這份 brief 描述把這條路接通到「資料齊備、隨時可切流量」的狀態,過程中使用者跟現有流量完全不受影響。
中範圍。七個 PR。Caddy 不動。
01 Where we are now
跑完 hack/check-cf-for-saas.sh 對兩個 prod zone,狀態如下。能用、半設好、沒接:
| Check | vibehost.com | vibehost.space | 解讀 |
|---|---|---|---|
| Token verify | active | active | runtime token 有效 |
| Zone status | Free Website | Free Website | quota=100 hostnames, HTTP DCV only |
| Custom Hostnames API | reachable | reachable | SaaS 真的開了——出乎意料 |
| Fallback origin | 1551 missing | 1551 missing | 沒 origin = CF 邊緣不知道流量送哪 |
| cname.<zone> record | missing | missing | 客戶要 CNAME 指過來的目標 |
Production 資料庫端 custom_domains 五筆 row:2 verified(有人在用)、2 unverified(建了沒設 DNS)、1 軟刪。所有流量目前都走 GCE 的 Caddy + Let's Encrypt,CF Analytics 看不到。
02 Scope of this slice
做
- API 在 verify 成功 / soft-delete 時雙寫進 CF Custom Hostnames + Workers KV。
- 新增 dispatcher Worker 部到
cname.vibehost.space。P1 階段故意只回 503 stub——不接流量、避免誤切。 - DB schema 多六個
cf_*欄位(migration 0019)。 - Pulumi 補 fallback origin、KV namespace、CNAME 記錄。
- 把現有 2 筆 verified domain backfill 進 CF(使用者 0 動作)。
- Reconciler cron 補打 fail-open 留下的 dirty row。
不做
- 不切流量——使用者 DNS 還是 TXT 驗證、CNAME 還是指 Caddy。
- 不改 CLI、不改 dashboard UI。
- 不拆 Caddy。
- 不接 access gates(visibility / password / share-link)到 dispatcher——這留給「大範圍」,dispatcher P1 故意 503 是要在工程上保證後續切流量前必須先做這件事。
- 不接 CF cert renewal webhook。
03 Architecture
新增的元件用 ★ 標。雙寫的兩條 fan-out 線(CF Hostnames API + Workers KV)顯示為 fail-open——要嘛同時成功,要嘛任一失敗都不擋主流程,由 reconciler 補。
┌─────────────────────────────────────┐
│ apps/api (existing) │
│ │
│ routes/custom-domains.ts │
│ add() / verify() / remove() │
│ │ │
│ ▼ │
│ services/custom-domains.ts │
│ commitVerify ─────┐ │
│ softDelete ─────┤ fail-open │
│ commitReclaim ─────┘ dual-write │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ services/cloudflare/ │
│ ★ custom-hostname.ts │
│ ★ kv-hostname-map.ts │
│ │
│ ★ jobs/cf-sync-reconciler.ts │
│ cron */5 * * * * │
└────────┬─────────────────┬──────────┘
│ HTTPS │ HTTPS
▼ ▼
┌──────────────┐ ┌────────────────┐
│ CF Custom │ │ Workers KV │
│ Hostnames │ │ HOSTNAME_KV │
│ API │ │ host → meta │
└──────┬───────┘ └────────┬───────┘
│ │
│ ssl/dcv lifecycle │ read
▼ ▼
┌──────────────────────────────────────┐
│ CF Edge │
│ fallback_origin = dispatcher worker │
│ cname.vibehost.space ─→ ★ │
│ │ │
│ ┌────────────────┘ │
│ ▼ │
│ ★ apps/dispatcher (Worker) │
│ 1. host = X-Forwarded-Host │
│ 2. kv.getWithMetadata(host) │
│ 3. env.DISPATCHER.get(name) │
│ .fetch(req) │
└──────────────────────────────────────┘
04 Flow · verify + dual-write
關鍵設計:主交易先 commit,雙寫放在交易外、fail-open。如果 CF 或 KV 抖一下,使用者 row 還是有 verified_at、Caddy 那邊照常服務,只是 cf_sync_state='failed' 等下次 reconciler 補。
vibehost domain add blog.example.com使用者觸發;CLI 不變、TXT 流程維持。
INSERT custom_domains (verify_token=…, cf_sync_state='pending')RES回傳 verifyToken;使用者去 DNS 後台設 TXT。
verify_token,比對成功才繼續。
BEGIN; UPDATE row SET verified_at = now(); COMMIT;這一步 commit 後使用者 verify 已成功——之後出什麼事都不該影響它。
fail-open
cf.create(hostname) → 拿到 cf_id、ssl_statusKV
kv.put(hostname, {workerName, channelId})SQL
UPDATE row SET cf_id, cf_ssl_status, cf_sync_state='ok', cf_synced_at任一步丟例外 → catch → row 標
cf_sync_state='failed' + cf_last_error,主流程繼續回 ok。
{ok: true, verified: true}流量繼續走 Caddy。CF 邊緣 ~60s 後 ssl_status 變 active,但 0 流量打進來——直到大範圍切 DNS。
使用者選擇「不加 feature flag」、「上就是上」。代表雙寫的容錯必須在 service 層就吸收掉所有 CF/KV 暫時性錯誤——不能讓使用者的 verify 因為 CF API 抖一下而失敗。代價是:dirty row(state=failed)必須有 reconciler 補。沒 reconciler 的 fail-open = bug 工廠。
05 Flow · delete & reclaim
Soft delete
BEGIN; UPDATE row SET deleted_at = now(); COMMIT;fail-open
kv.delete(hostname) 邊緣立刻 404CF
cf.delete(cf_id) 清掉占 quota 的 record失敗 → log;reconciler query 2 掃
deleted_at IS NOT NULL AND cf_id IS NOT NULL 補刪。
Reclaim · Vercel-style 跨 workspace 拿回 hostname
現有 schema 已支援 reclaim(reclaim_of 欄位 + commitReclaim() 一次 TX 內升級新 row、軟刪舊 row)。雙寫怎麼接它:
cf.create(hostname) → 1414/1415(hostname 早在帳號裡) → cf.findByHostname(hostname) 拿回現有 idSQL
UPDATE newRow SET cf_id, cf_sync_state='ok'KV
kv.put(hostname, {新 workerName, 新 channelId}) last-write-wins
UPDATE oldRow SET cf_id = NULL不呼叫
cf.delete——hostname 還在用、record 屬於 VibeHost、只是換了 app 而已。把舊 row 的 cf_id 設 NULL 是為了避免 reconciler 之後誤刪這條 record。
06 Error matrix
每個分支都有測試覆蓋(§7 Testing)。Hover row 會反白方便對讀。
| Scenario | Outcome |
|---|---|
cf.create 5xx / network |
row → cf_sync_state='failed', cf_last_error, cf_retry_count++。Verify 主流程回 ok。Reconciler 5 分鐘內補。 |
cf.create 1414 / 1415(hostname 已在本帳號) |
findByHostname 取回 id → 寫進 row。Sync state → ok。發生在 reclaim 與 重複新增。 |
cf.create 1416(hostname 在別人 CF 帳號) |
row → cf_sync_state='blocked'。Reconciler 跳過,需人工。 |
| KV put 失敗但 CF create 成功 | row → cf_sync_state='failed',cf_id 仍寫入。Reconciler 重跑——CF 那步走 1414 分支變成 idempotent。 |
| Pulumi fallback origin 還沒 active | 不影響雙寫。CF record + cert 照樣 issue;只是真切 DNS 過去前邊緣不通。中範圍流量本就不切,無關痛癢。 |
| Token 缺 SSL/TLS:Edit | 所有 cf 呼叫 401/403 → 全部 row failed。Pulumi post-deploy smoke 直接擋下來,部署不會通過。 |
| Reclaim 但 CF hostname 還在舊 owner KV metadata | commitReclaim 後直接 kv.put 新 metadata 覆寫——last-write-wins,邊緣 ≤60s 一致。 |
| 軟刪後 CF delete 失敗 | KV 已刪 → 邊緣立刻 404。CF 還留 record → reconciler query 2 補刪。 |
07 PR breakdown
七個 PR。點任一條展開細節。每個都跑既有的 ship-feature 流程:CI claude-review ≥ 7.5 → squash merge → staging E2E → prod。
- 新增
cloudflare.WorkersKvNamespacevibehost-hostname-map-{env},wire 到 api Deployment + dispatcher binding。 - 新增
cloudflare.CustomHostnameFallbackOriginonvibehost.space,origin = dispatcher worker host。 - 新增
cloudflare.Recordproxied CNAMEcname.vibehost.space → dispatcher。 - 新增
apps/dispatcher/——故意只回503 cf-for-saas dispatcher: not yet serving traffic加 log line。 - Pulumi post-deploy smoke:
GET /custom_hostnames?per_page=1必須成功,否則整個 deploy fail。
CloudflareCustomHostnameService· create / get / findByHostname / delete。HostnameKvMap· put / delete(用 KV metadata,dispatcher 一個 round trip 拿到)。CfHostnameError區分 1414/1415/1416 vs 其他。- fetch mock 矩陣:200 / 1414 / 1415 / 1416 / 5xx / network。
0019_custom_domains_cf_sync.sql。六個 nullable 欄位、預設 sentinel。
cf_id text、cf_ssl_status text、cf_sync_state text DEFAULT 'pending'、cf_last_error text、cf_synced_at timestamptz、cf_retry_count int DEFAULT 0。- schema.ts 同步、tsc 通過。
- Index on
(cf_sync_state) WHERE deleted_at IS NULL給 reconciler 用。
- Staging 先 deploy → 手動建一筆 domain → 看 CF dashboard 有沒有 record、ssl_status 是不是會跑起來。
- Unit tests 蓋三個 catch 分支:成功、5xx fail-open、1414 recover、1416 blocked。
- commitReclaim 的 1414 + KV 覆寫測試獨立一條。
cf_sync_state='failed' 補打、掃孤兒 cf_id 補刪。
- BullMQ 既有風格、不引入新基礎設施。
cf_retry_count < 10cap、超過停下來等人工。blockedstate 永遠跳過。
--dry-run 先跑、再實跑。Staging + prod 各 2 筆 row。
- 跟 verify-time 雙寫共用同一個 helper——共用一致性。
- Idempotent:cf_id 已寫的 row 跳過。
- 對稱寫
--cleanup模式(萬一要 rollback 用)。
- 狀態紀錄:CF 那邊 record 齊、cert 都有、KV 寫好;流量還在 Caddy。
- 明列「大範圍 prerequisites」:access gates 三檢查移植、CLI 改 CNAME instructions、舊 domain DNS 引導、Caddy 退役。
- Roadmap 補一條 ticket。
08 Hard-rule alignment
VibeHost CLAUDE.md 列了九條 hard rule。本次改動觸到三條:
本次只是 HTTP 呼叫 + DB 寫入,沒 build、沒 wrangler 子程序、沒 npm install。無關。
沿用既有 vibehost:cfApiToken Pulumi config key。如果現有 token scope 不夠 SSL/TLS:Edit,原地換新 token,不引入新 env var。Pulumi smoke step 會在部署當下確認 scope。
Dispatcher worker 在 P1–P6 不評估 visibility / password / share-link。三個檢查在現有 custom-domain-proxy.ts middleware 裡,dispatcher 沒移植。P1 故意 503 stub 是為了在工程上保證之後切流量前必須先做這件事(否則切過去會直接繞過 access gate)。P7 把這個 gap 寫進 ADR。
09 Out of scope · 大範圍要做的
等中範圍 ship 完、CF 那邊資料齊備之後,「大範圍」會做:
- 把 visibility / password / share-link 三個 access gate 從
middleware/custom-domain-proxy.ts移植到 dispatcher worker(或 sidecar)。 - CLI / dashboard 把 DNS 指引從 TXT → CNAME(或雙軌、TXT 列為 legacy)。
- CF cert renewal lifecycle webhook receiver。
- Caddy on GCE 退役。
- 需要的話,zone 升級到 SSL-for-SaaS Advanced(quota > 100 hostnames)。