Astro 靜態網站加入新文章推播通知:Cloudflare Worker、D1 與 Queue 的 Web Push 設計

摘要

Astro 靜態網站不需要改成伺服器端渲染(SSR),只要另外部署一支 Cloudflare Worker,搭配 D1 資料庫與 Queue 佇列,就能用 Web Push 推送新文章通知。網站負責輸出 Service Worker 與文章清單,Worker 負責管理訂閱、判斷哪些是新文章,並在每天 20:00 送出通知。在免費方案下大約可以支撐 3,300 台裝置,但不保證每則通知都會送達,失敗了也不會補送。

文章目錄

Astro 靜態網站要加入新文章的推播通知,網站本身不需要改成伺服器端渲染(SSR)。網站只要在 Build 時多輸出一個 Service Worker 和一份文章清單,訂閱的管理與通知的發送則交給另一支 Cloudflare Worker 處理。讀者平常瀏覽文章時,完全不會呼叫到這支 Worker。

本站首頁的「訂閱新文章通知」連結就是用這套設計做的。讀者點下連結之後,瀏覽器才會詢問是否允許網站發送通知。

本站首頁分類列右側有「訂閱新文章通知」與「用 RSS 追蹤新文章」兩個連結

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

Chrome 跳出 vervecode.dev 要求顯示通知的權限視窗,下方有允許與封鎖按鈕,頁面連結變成「正在設定通知…」

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

Windows 通知中心裡 Google Chrome 的通知,標題為 Minecraft 基岩版光影資源包推薦與安裝教學,內文為 VerveCode 新文章,來源為 127.0.0.1:4321

讀者實際會看到的只有這三個畫面,其他步驟都在網站與 Worker 之間自動完成。

設計

後面的各種機制,都是依照本站設定的這幾條規則設計出來的:

  • 讀者主動點擊連結後才詢問通知權限,進入網站時不會跳出詢問。
  • 一天最多發送一則通知,固定在台灣時間 20:00 發送。從發佈文章到讀者收到通知,最久大約要等一天。
  • 每台裝置每晚最多只嘗試發送一次,發送失敗或逾時都不會補送。
  • 只使用 Cloudflare 的免費方案,當天額度用完就停止發送,等到隔天再恢復。

架構

網站和推播功能分別部署成兩支 Worker。網站那支只包含靜態檔案,讀者瀏覽時不收費,也不會計入 Worker 的請求次數。推播那支 Worker 只在讀者訂閱、排程執行與發送通知時才會運作,因此文章的瀏覽量再高,也不會用掉推播功能的額度。

架構圖:讀者瀏覽器向網站瀏覽文章、向推播 Worker 訂閱,推播 Worker 每小時讀取網站的 push-manifest.json,Queue Consumer 經瀏覽器推播服務送到 Service Worker 顯示通知

訂閱

頁面載入時只看瀏覽器本身的訂閱狀態,讀者點擊連結並允許通知後,才會載入 Turnstile 人機驗證。訂閱請求送到 Worker 後,會依序經過多道檢查,全部通過才會寫入 D1 資料庫。

訂閱流程:網站頁面要求權限、向 Turnstile 取得 token、建立瀏覽器訂閱,再 POST 到推播 Worker,通過 WAF、Origin、限流、Endpoint 白名單與 Siteverify 後 upsert 到 D1

owner 是瀏覽器在訂閱時產生的一組隨機值,D1 只儲存它的雜湊值。之後要更新或取消訂閱時,Worker 都會比對這個值。就算有人知道某台裝置的推播網址(endpoint),只要沒有對應的 owner,也無法修改或取消別人的訂閱。

從發文到通知

網站只負責列出所有已發佈的文章,哪幾篇算是新文章,則由 Worker 判斷。Worker 每小時檢查一次文章清單,把新文章記錄到資料庫。每天 20:00 發送時,會把過去 24 小時內記錄的文章合併成一則通知。

發文到通知的流程:每小時從 manifest 篩選出 3 天內發表、尚未記錄、同一輪不超過 10 篇且頁面已回應 200 的文章,記錄到 articles。每天 20:00 合併成一則通知,每頁 99 台分頁放入 Queue,Consumer 先寫入發送紀錄再送到推播服務

只接受 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 對不上實際路徑的文章永遠不會被記錄為新文章。

訂閱與取消的前端流程

頁面載入時,腳本只讀取瀏覽器目前的推播訂閱,決定連結顯示「訂閱」或「取消」。點擊訂閱後依序執行以下步驟,任一步失敗就停止並在原位顯示錯誤訊息:

  1. 呼叫 Notification.requestPermission()。瀏覽器只在使用者操作的當下允許詢問通知權限,所以這一步必須排在所有非同步工作之前。
  2. 動態載入 Turnstile,以 action: push_subscribe 取得一次性 token,取得後立刻移除 Widget。
  3. 從 localStorage 讀取 owner,沒有就產生 32 bytes 的隨機值並存回去。
  4. 以 scope / 註冊 /push-sw.js,再用 VAPID 公鑰建立訂閱(userVisibleOnly: true)。瀏覽器已有訂閱但 owner 遺失時,先取消舊訂閱再重建。
  5. 把 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 當天發送時段的結束時間 同左

收到推播後的檢查與點擊後的行為如下:

Service Worker 的處理流程:push 事件依序解析 JSON、檢查欄位、檢查 path、在 IndexedDB 認領通知 ID,任一步不通過就不顯示通知。notificationclick 事件先關閉通知,取得同源網址,有同網址視窗就 focus,沒有就 openWindow

資料結構

推播 Worker 用六張資料表記錄訂閱、文章與發送狀態:

D1 資料結構的 ER 圖:subscriptions 與 digests 各對應零或多筆 deliveries,digests 對應零或多筆 outbox 與 articles,control 是只有一列的全域設定

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 依序執行:

  1. 總開關關閉或不在發送時段內就結束。
  2. 以 INSERT OR IGNORE 建立當天的 digests,寫入失敗代表當天已經處理過,直接結束。
  3. 把還沒發送、而且在 cutoff 前記錄的文章標記為當天的通知,只取最近 24 小時內記錄的文章產生通知內容。沒有文章就結束。
  4. 處理第一頁訂閱名單。只有 cutoff 前建立的訂閱會被選入,20:00 之後才訂閱的裝置當晚不會收到通知。

每一頁名單的處理方式如下:

處理一頁訂閱名單的流程:先以 INSERT OR IGNORE 認領頁次,讀取 id 大於 cursor 的 99 筆有效訂閱,每台寫一列 outbox,再確認時段與總開關,最後用 sendBatch 送出 99 則單台訊息與 1 則帶 cursor 的下一頁訊息。Consumer 收到下一頁訊息時回到第一步

一頁 99 台加上下一頁的訊息剛好 100 則,是 sendBatch 一次能送出的上限。Consumer 收到單台訊息後的處理與發送結果如下:

Consumer 處理一則單台訊息的流程:讀取 outbox 與當天通知、查詢訂閱與 control、確認總開關與時段、先寫入發送紀錄,再加密並送出。右側表格列出發送結果,endpoint 不在白名單、加密例外與 404、410 會停用訂閱,其他結果只記錄

在 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 * * * * 只設在正式環境

部署到正式環境的步驟如下:

  1. 建立 D1 資料庫與 Queue,把 D1 的 ID 填進 env.production。
  2. 依前面的資料結構與索引寫好 migration 檔並套用到正式資料庫。
  3. 用 wrangler secret put 設定 VAPID_PRIVATE_KEY、TURNSTILE_SECRET_KEY 與 ADMIN_TOKEN。
  4. 在網站所屬的 Cloudflare Zone 建立前面提到的 WAF 規則與 Access 設定。
  5. 本機測試通過後,以 wrangler deploy --env production 部署。
  6. 確認一切正常後,把 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 的用量大約會降到原本的十分之一。
  • 可改善:目前只記錄推播服務是否接受了通知,還沒有統計裝置實際顯示通知與讀者點擊的情況。
下一篇Minecraft Java 26.3 切換 Vulkan 教學,實測 FPS 與 OpenGL 差異Games
Ted Liou

Ted Liou

Unity 現役工程師,Unity、AI 技術開發經驗分享與諮詢。