文章目錄

Astro 靜態網站要加入新文章的推播通知,網站本身不需要改成伺服器端渲染(SSR)。網站只要在 Build 時多輸出一個 Service Worker 和一份文章清單,訂閱的管理與通知的發送則交給另一支 Cloudflare Worker 處理。讀者平常瀏覽文章時,完全不會呼叫到這支 Worker。
本站首頁的「訂閱新文章通知」連結就是用這套設計做的。讀者點下連結之後,瀏覽器才會詢問是否允許網站發送通知。

以 Chrome 為例,點擊後網址列下方會跳出「vervecode.dev 要求下列權限:顯示通知」的視窗。

按下允許後,只要當天有新文章,讀者就會在 20:00 收到一則系統通知,點擊通知就會開啟文章。在 Windows 上,Chrome 會把通知交給系統顯示,之後也會保留在通知中心。以下是本機測試時送出的通知,來源因此顯示為 127.0.0.1:4321。

讀者實際會看到的只有這三個畫面,其他步驟都在網站與 Worker 之間自動完成。
設計
後面的各種機制,都是依照本站設定的這幾條規則設計出來的:
- 讀者主動點擊連結後才詢問通知權限,進入網站時不會跳出詢問。
- 一天最多發送一則通知,固定在台灣時間 20:00 發送。從發佈文章到讀者收到通知,最久大約要等一天。
- 每台裝置每晚最多只嘗試發送一次,發送失敗或逾時都不會補送。
- 只使用 Cloudflare 的免費方案,當天額度用完就停止發送,等到隔天再恢復。
架構
網站和推播功能分別部署成兩支 Worker。網站那支只包含靜態檔案,讀者瀏覽時不收費,也不會計入 Worker 的請求次數。推播那支 Worker 只在讀者訂閱、排程執行與發送通知時才會運作,因此文章的瀏覽量再高,也不會用掉推播功能的額度。
訂閱
頁面載入時只看瀏覽器本身的訂閱狀態,讀者點擊連結並允許通知後,才會載入 Turnstile 人機驗證。訂閱請求送到 Worker 後,會依序經過多道檢查,全部通過才會寫入 D1 資料庫。
owner 是瀏覽器在訂閱時產生的一組隨機值,D1 只儲存它的雜湊值。之後要更新或取消訂閱時,Worker 都會比對這個值。就算有人知道某台裝置的推播網址(endpoint),只要沒有對應的 owner,也無法修改或取消別人的訂閱。
從發文到通知
網站只負責列出所有已發佈的文章,哪幾篇算是新文章,則由 Worker 判斷。Worker 每小時檢查一次文章清單,把新文章記錄到資料庫。每天 20:00 發送時,會把過去 24 小時內記錄的文章合併成一則通知。
只接受 3 天內發表的文章,是為了在第一次上線或停機後恢復時,不會把以前的文章全部重新推送一次。同一輪出現超過 10 篇新文章時整批都不記錄,大量搬移文章或日期寫錯時就會被擋下來。這樣一次執行對外發出的請求,最多就是 1 份清單加上 10 個文章頁面,低於免費方案每次執行 50 個請求的上限。這些篩選規則只看網址和發表時間,如果一篇舊文章同時改了網址,又把發表時間改成最近的日期,仍然會被當成新文章發送通知。
「每台裝置每晚最多發送一次」是靠資料庫的三層主鍵達成的。每天的通知內容以日期當主鍵,同一天只會建立一份。每一頁訂閱名單在放進佇列前,會先寫入一筆紀錄,確保同一頁不會重複處理。每台裝置在真正發送前,也會先寫入一筆發送紀錄。Cloudflare Queues 保證每則訊息至少送達一次,所以同一則訊息有可能被處理兩次,第二次處理時會發現發送紀錄已經存在,就直接跳過。這樣做的代價是,如果程式在寫入紀錄之後、真正發送之前中斷,這台裝置當晚就收不到通知。
免費方案的容量
Workers 免費方案每次執行的 CPU 時間上限是 10 ms。發送前的 Web Push 加密必須逐台進行,是 Consumer 最耗 CPU 的工作,正式環境實測一次處理 4 台就用了 30 ms。因此本設計的每則 Queue 訊息只對應一台裝置,Consumer 每次執行只加密並發送一則通知。
一則訊息只發給一台裝置,代表訊息數量會跟著裝置數量增加,Queue 的額度就成了第二個瓶頸。Queues 免費方案每天有 10,000 次操作,每則訊息的寫入、讀取與刪除各算一次:
1訊息數 ≈ N(裝置數)+ ⌈N / 99⌉ − 1(翻頁)
2Queue 操作 ≈ 3 × 訊息數
3N = 1,000 → 約 3,030 次操作
4上限 3 × (N + N / 99) ≤ 10,000 → N ≈ 3,300 台
根據 2026-10-04 的 Workers 限制與 Workers 定價資訊,Worker 每天有 10 萬次請求、D1 每天有 10 萬列寫入的免費額度,在 3,300 台的規模下都很充足。訂閱裝置超過大約 3,300 台時,當晚的發送會在 Queue 額度用完時中斷,依照設計也不會補送。到了這個規模,就要改用付費的 Workers Paid 方案,讓一則訊息可以發送給多台裝置。
實作要點
網站端的公開設定
Web Push 需要一組 VAPID 金鑰,可以用 npx web-push generate-vapid-keys 產生,本機環境與正式環境各準備一組。Turnstile 要在 Cloudflare 後台建立一個 Widget,允許的網域只填正式網站的網域。私鑰與其他機密資料只放在推播 Worker 裡,網站只需要以下三種環境的公開設定:
| 環境 | 訂閱連結 | API 位址 | VAPID 公鑰 | Turnstile sitekey |
|---|---|---|---|---|
本機(astro dev) |
顯示 | 本機 wrangler dev 的位址 |
本機用的公鑰 | Cloudflare 的測試 sitekey |
預覽(非 main 分支) |
不顯示 | 不需要 | 不需要 | 不需要 |
| 正式 | 顯示 | 推播 Worker 的正式網址 | 正式用的公鑰 | 正式 Widget 的 sitekey |
訂閱元件在 Build 時依環境選出其中一組,以 data-* 屬性輸出到 HTML。Workers Builds 建置時會提供 WORKERS_CI_BRANCH 環境變數,用它判斷是不是預覽分支,正式資料庫因此不會混入從預覽網址建立的訂閱。
文章清單
文章清單是 Build 時輸出的靜態檔 /push-manifest.json,內容是一個 articles 陣列,只列出已發佈而且發表時間已到的文章。每篇文章有四個欄位:
| 欄位 | 內容 |
|---|---|
id |
文章網址的 SHA-256 雜湊,排成 UUID 格式,不需要在 front matter 另外維護 |
url |
以 / 開頭與結尾、不含查詢字串,而且不會再被轉址的最終路徑 |
title |
文章標題,最多 200 字 |
published |
發表時間,ISO 8601 格式 |
Worker 確認文章是否已上線時不會跟隨轉址,url 對不上實際路徑的文章永遠不會被記錄為新文章。
訂閱與取消的前端流程
頁面載入時,腳本只讀取瀏覽器目前的推播訂閱,決定連結顯示「訂閱」或「取消」。點擊訂閱後依序執行以下步驟,任一步失敗就停止並在原位顯示錯誤訊息:
- 呼叫
Notification.requestPermission()。瀏覽器只在使用者操作的當下允許詢問通知權限,所以這一步必須排在所有非同步工作之前。 - 動態載入 Turnstile,以
action: push_subscribe取得一次性 token,取得後立刻移除 Widget。 - 從
localStorage讀取owner,沒有就產生 32 bytes 的隨機值並存回去。 - 以 scope
/註冊/push-sw.js,再用 VAPID 公鑰建立訂閱(userVisibleOnly: true)。瀏覽器已有訂閱但owner遺失時,先取消舊訂閱再重建。 - 把
endpoint、p256dh、auth、owner與 token POST 到/subscribe。寫入失敗時撤銷這次新建立的訂閱,畫面才不會顯示已訂閱、資料庫卻沒有紀錄。
取消時,用同樣的欄位(不需要 token)POST 到 /unsubscribe,再取消瀏覽器的訂閱。
在 iOS 與 iPadOS 16.4 以上的版本,讀者必須先把網站加入主畫面才能詢問通知權限。網站要提供設定了 display: standalone 的 manifest.webmanifest,在 iOS 上不是從主畫面開啟時,點擊連結只顯示加入主畫面的提示。
Service Worker
/push-sw.js 只處理推播,沒有 fetch handler,也不快取任何文章。推播 Worker 送來的通知內容如下:
| 欄位 | 只有一篇新文章 | 有多篇新文章 |
|---|---|---|
id |
通知日期 YYYY-MM-DD |
同左 |
title |
文章標題 | 新增 N 篇文章 |
body |
固定的說明文字 | 前 3 篇標題 |
path |
文章網址 | / |
expires |
當天發送時段的結束時間 | 同左 |
收到推播後的檢查與點擊後的行為如下:
資料結構
推播 Worker 用六張資料表記錄訂閱、文章與發送狀態:
deliveries 以「通知日期 + 訂閱 ID」作為複合主鍵,這是每台裝置每晚最多發送一次的依據。control.enabled 是整個推播功能的總開關(Kill switch),預設為 0,發送流程的每個步驟都會先檢查它。
D1 免費方案在每天讀寫的資料列數超過額度時會拒絕查詢,所以每個查詢都要有索引,避免掃描整張資料表:
| 資料表 | 索引欄位 | 用途 |
|---|---|---|
subscriptions |
active, id |
依 ID 順序分頁讀取有效訂閱 |
subscriptions |
active, updated_at |
清除已停用的舊訂閱 |
articles |
digest_id, accepted_at |
找出還沒發送過的新文章 |
digests |
expires |
清除過期的通知 |
outbox |
digest_id |
清除過期的發送工作 |
deliveries |
subscription_id |
清除訂閱前確認沒有發送紀錄 |
訂閱 API 的防護
訂閱 API 是任何人都能呼叫的公開端點。下表依執行順序排列,前面能擋下的請求不會進到後面:
| 檢查 | 做法 |
|---|---|
| WAF 自訂規則 | 推播網域只允許訂閱、取消、健康檢查與管理這幾個路徑 |
| WAF 限流 | /push-api/ 開頭的請求每個 IP 每 10 秒最多 10 次,在 Worker 執行前擋下 |
| Cloudflare Access | 管理路徑要求 Service Auth,Worker 內再比對 ADMIN_TOKEN |
| 只保留自訂網域 | 關閉 workers_dev 與 preview_urls,避免繞過 WAF |
| Origin | 只接受 ALLOWED_ORIGINS 列出的網站 |
| Rate Limiting binding | 每個 IP 每分鐘最多 10 次,保護 D1 寫入 |
| 請求格式 | 只接受 16 KB 以內的 JSON,owner 與加密金鑰的長度格式要正確 |
| Endpoint 白名單 | 只接受 FCM、Mozilla、Apple 推播服務的 HTTPS 網址,避免 Worker 被利用來對任意網址發送請求 |
| Turnstile | 訂閱時以 Siteverify 確認 success、hostname 等於請求來源、action 等於 push_subscribe |
訂閱的 id 是 endpoint 的雜湊。寫入時用一條 upsert,把 owner 的比對寫在 ON CONFLICT ... WHERE 裡,所有權不符時不會更新任何資料,API 回傳 409:
1INSERT INTO subscriptions(id,endpoint,p256dh,auth,owner_hash,created_at,updated_at)
2VALUES(?,?,?,?,?,?,?)
3ON CONFLICT(id) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth, active=1,
4 created_at=excluded.created_at, updated_at=excluded.updated_at
5WHERE subscriptions.owner_hash = excluded.owner_hash;
取消訂閱是把 id 與 owner_hash 都相符的那一列改成停用。
每小時記錄新文章
Cron 30 * * * * 依前面流程圖的規則記錄新文章。讀取清單與確認文章頁面時都不跟隨轉址,並設定 10 秒逾時,清單讀取失敗就放棄這一輪。發表時間比現在晚 1 天以上的文章,也和超過 3 天的舊文章一樣略過。
同一個 Cron 也會分批清除 30 天前的 deliveries、outbox、digests 與已停用的訂閱。articles 永久保留,舊文章才不會被重新記錄成新文章。
每天 20:00 發送通知
Cron 0 12 * * * 是 UTC 12:00,也就是台灣時間 20:00。實際觸發會晚幾十秒,程式先把時間對齊到當天 12:00 UTC 作為基準時間 cutoff,只在 cutoff 起的 15 分鐘內發送。排程觸發後,Worker 依序執行:
- 總開關關閉或不在發送時段內就結束。
- 以
INSERT OR IGNORE建立當天的digests,寫入失敗代表當天已經處理過,直接結束。 - 把還沒發送、而且在
cutoff前記錄的文章標記為當天的通知,只取最近 24 小時內記錄的文章產生通知內容。沒有文章就結束。 - 處理第一頁訂閱名單。只有
cutoff前建立的訂閱會被選入,20:00 之後才訂閱的裝置當晚不會收到通知。
每一頁名單的處理方式如下:
一頁 99 台加上下一頁的訊息剛好 100 則,是 sendBatch 一次能送出的上限。Consumer 收到單台訊息後的處理與發送結果如下:
在 Workers 送出 Web Push
Web Push 的內容必須用每台裝置各自的金鑰加密(RFC 8291),請求也要附上 VAPID 簽章(RFC 8292)。這兩件事交給 web-push 套件的 generateRequestDetails 處理,Worker 因此需要開啟 nodejs_compat。TTL 設為發送時段剩餘的秒數,topic 用通知 ID,讓推播服務以新通知取代同一天還沒送達的舊通知。實際發送改用 Workers 內建的 fetch,不跟隨轉址,設定 10 秒逾時,也不重試。
Worker 設定與部署
推播 Worker 的 wrangler.jsonc 需要以下設定。本機的寫在設定檔最上層,正式的寫在 env.production:
| 設定 | 值 | 用途 |
|---|---|---|
compatibility_flags |
["nodejs_compat"] |
讓 web-push 套件能在 Workers 上執行 |
workers_dev、preview_urls |
false |
只保留自訂網域 |
vars |
ENVIRONMENT、ALLOWED_ORIGINS、SITE_ORIGIN、VAPID_PUBLIC_KEY |
環境、允許的來源、文章清單位置與公鑰 |
d1_databases |
綁定為 DB |
資料庫 |
queues |
producer 綁定為 PUSH_QUEUE,consumer 設 max_batch_size: 1、max_retries: 0 |
每次處理一則訊息,失敗不重試 |
ratelimits |
每 60 秒 10 次 | 訂閱 API 的 Rate Limiting binding |
triggers.crons |
0 12 * * *、30 * * * * |
只設在正式環境 |
部署到正式環境的步驟如下:
- 建立 D1 資料庫與 Queue,把 D1 的 ID 填進
env.production。 - 依前面的資料結構與索引寫好 migration 檔並套用到正式資料庫。
- 用
wrangler secret put設定VAPID_PRIVATE_KEY、TURNSTILE_SECRET_KEY與ADMIN_TOKEN。 - 在網站所屬的 Cloudflare Zone 建立前面提到的 WAF 規則與 Access 設定。
- 本機測試通過後,以
wrangler deploy --env production部署。 - 確認一切正常後,把
control.enabled改成 1 開啟發送功能,需要暫停時改回 0。
在本機測試真實推播
本站沒有另外架設預覽用的推播 Worker,而是在本機用 wrangler dev 執行同一份程式碼。D1、Queue 與 Rate Limiting 由本機模擬,推播則真的送到 Google 的 FCM 推播服務。本機的機密值放在不加入版本控制的 .dev.vars。
Turnstile 使用 Cloudflare 公開的測試金鑰,一定會通過驗證。測試 secret 回傳的 hostname 與 action 是固定的測試值,所以 Worker 只在 ENVIRONMENT=local 而且 secret 等於測試值時,改成只檢查 success。調整時間偏移、手動記錄文章與手動觸發發送的管理 API 也只在本機開放,正式環境一律回傳 404。
測試腳本用 Playwright 驅動電腦上已安裝的 Google Chrome(channel: 'chrome'),因為 Playwright 內建的 Chromium 沒有推播服務。腳本先確認 Worker 是本機環境,以用完即丟的使用者設定檔訂閱本機網站,再把 Worker 的時間調到發送時段內,手動記錄一篇文章並觸發發送。最後確認發送結果都是 accepted,而且 Chrome 顯示了這篇文章的通知。結束時一定會取消訂閱、關閉總開關並把時間還原。
確認功能是否正常時,要分開檢查四件事:推播服務是否接受請求、瀏覽器是否收到、作業系統是否顯示,以及點擊後是否開啟正確的頁面。推播服務回傳 201 只代表接受了這則通知,作業系統關閉通知或開啟勿擾模式時,畫面上不會跳出任何通知。
總結
靜態網站要加入推播通知,關鍵在於把工作分清楚:網站負責輸出公開設定、Service Worker 與文章清單,推播 Worker 負責判斷新文章並發送通知。新文章的判斷依據是 3 天內發表、而且資料庫中還沒有紀錄。發送時則靠通知內容、訂閱名單分頁與單台裝置三層主鍵,確保每台裝置每晚最多只嘗試發送一次。在 Workers 免費方案下,CPU 時間上限讓一則訊息只能發送給一台裝置,Queue 的額度則把可支撐的裝置數限制在大約 3,300 台,超過之後改用付費方案就能放寬。
此設計方案的優缺點統整如下:
- 優點:網站維持純靜態,讀者瀏覽文章時不會呼叫 Worker,瀏覽量再高也不會用掉推播的額度。
- 優點:發佈文章時只要照平常的方式部署,Worker 會自己從文章清單找出新文章,不需要手動記錄新文章,也不需要在本機保管管理用的憑證。
- 缺點:每台裝置每晚最多只嘗試發送一次,不保證一定送達,發送失敗、逾時或額度用完時都不會補送。
- 缺點:免費方案大約在 3,300 台裝置時就會用完 Queue 的額度,而且從發文到收到通知,最久要等大約一天。
- 可改善:改用 Workers Paid 方案後,可以讓一則訊息發送給多台裝置,例如一則發送給 10 台時,Queue 的用量大約會降到原本的十分之一。
- 可改善:目前只記錄推播服務是否接受了通知,還沒有統計裝置實際顯示通知與讀者點擊的情況。