fix(web): re-subscribe push when VAPID key changes (stale hub subscriptions)

This commit is contained in:
2026-08-02 21:26:30 +08:00
parent ee13e85e27
commit 9cbb242580
+56 -6
View File
@@ -21,6 +21,30 @@ function base64UrlToUint8Array(base64Url: string): Uint8Array {
return output return output
} }
/**
* VAPID public key that the currently stored push subscription was created
* with. When the hub changes (or its keys rotate), existing browser
* subscriptions become undeliverable (push services reject them with
* VapidPkHashMismatch), so we compare and re-create them on the next load.
*/
const PUSH_VAPID_KEY_STORAGE = 'hapi.push.vapidKey'
function readStoredVapidKey(): string | null {
try {
return localStorage.getItem(PUSH_VAPID_KEY_STORAGE)
} catch {
return null
}
}
function writeStoredVapidKey(publicKey: string): void {
try {
localStorage.setItem(PUSH_VAPID_KEY_STORAGE, publicKey)
} catch {
// Ignore storage errors — the key check degrades to a re-subscribe.
}
}
export function usePushNotifications(api: ApiClient | null) { export function usePushNotifications(api: ApiClient | null) {
const [isSupported, setIsSupported] = useState(false) const [isSupported, setIsSupported] = useState(false)
const [permission, setPermission] = useState<NotificationPermission>('default') const [permission, setPermission] = useState<NotificationPermission>('default')
@@ -43,8 +67,18 @@ export function usePushNotifications(api: ApiClient | null) {
const registration = await navigator.serviceWorker.ready const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription() const subscription = await registration.pushManager.getSubscription()
setIsSubscribed(Boolean(subscription)) let keyMatches = true
}, []) if (subscription && api) {
try {
const { publicKey } = await api.getPushVapidPublicKey()
keyMatches = readStoredVapidKey() === publicKey
} catch {
// Key lookup failed — keep the existing subscription benefit of
// the doubt rather than showing a false "off" state.
}
}
setIsSubscribed(Boolean(subscription) && keyMatches)
}, [api])
useEffect(() => { useEffect(() => {
void refreshSubscription() void refreshSubscription()
@@ -78,10 +112,26 @@ export function usePushNotifications(api: ApiClient | null) {
const existing = await registration.pushManager.getSubscription() const existing = await registration.pushManager.getSubscription()
const { publicKey } = await api.getPushVapidPublicKey() const { publicKey } = await api.getPushVapidPublicKey()
const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer const applicationServerKey = base64UrlToUint8Array(publicKey).buffer as ArrayBuffer
const subscription = existing ?? await registration.pushManager.subscribe({ // A subscription created against a previous hub or VAPID key can
userVisibleOnly: true, // never receive notifications from the current hub. Detect the
applicationServerKey // mismatch via the key recorded at subscribe time and recreate it.
}) let subscription = existing
if (existing && readStoredVapidKey() !== publicKey) {
try {
await existing.unsubscribe()
} catch {
// Ignore unsubscribe failures — subscribe() below still
// issues a fresh subscription with the current key.
}
subscription = null
}
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey
})
}
writeStoredVapidKey(publicKey)
const json = subscription.toJSON() const json = subscription.toJSON()
const keys = json.keys const keys = json.keys