@@ -6,8 +6,8 @@ const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
66
77const SUBSCRIPTION_TIMESTAMP_KEY = 'docsplus_push_subscription_timestamp'
88
9- // Subscriptions can expire, so re-subscribe once one is older than 30 days .
10- const SUBSCRIPTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000
9+ // Save again at most daily: the server may have switched this device off .
10+ const SUBSCRIPTION_MAX_AGE_MS = 24 * 60 * 60 * 1000
1111
1212export type PushErrorCode =
1313 | 'NOT_SUPPORTED'
@@ -99,6 +99,16 @@ function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
9999 return outputArray . buffer
100100}
101101
102+ // A null key means the browser does not say; treat it as current so we never churn it.
103+ function hasCurrentKey ( subscription : PushSubscription , key : string ) : boolean {
104+ const current = subscription . options . applicationServerKey
105+ if ( ! current ) return true
106+ const a = new Uint8Array ( current )
107+ const b = new Uint8Array ( urlBase64ToUint8Array ( key ) )
108+ if ( a . length !== b . length ) return false
109+ return a . every ( ( byte , i ) => byte === b [ i ] )
110+ }
111+
102112function arrayBufferToBase64 ( buffer : ArrayBuffer | null ) : string {
103113 if ( ! buffer ) return ''
104114 const bytes = new Uint8Array ( buffer )
@@ -231,10 +241,12 @@ export async function registerPushSubscription(): Promise<string | null> {
231241 for ( let attempt = 1 ; attempt <= 3 ; attempt ++ ) {
232242 try {
233243 const existing = await registration . pushManager . getSubscription ( )
234- if ( existing ) {
244+ if ( existing && hasCurrentKey ( existing , VAPID_PUBLIC_KEY ) ) {
235245 subscription = existing
236246 break
237247 }
248+ // A subscription made with another VAPID key gets 403 on every send.
249+ await existing ?. unsubscribe ( )
238250
239251 subscription = await registration . pushManager . subscribe ( {
240252 userVisibleOnly : true ,
@@ -279,30 +291,32 @@ export async function registerPushSubscription(): Promise<string | null> {
279291 return getDeviceId ( )
280292}
281293
294+ /**
295+ * The RPC goes first because sign-out calls this while the session is still valid.
296+ * getRegistration() resolves at once with no worker, where serviceWorker.ready would hang.
297+ * The stamp is cleared even on failure, so the load path never resubscribes an opt-out.
298+ */
282299export async function unregisterPushSubscription ( ) : Promise < boolean > {
283300 try {
284- const registration = await navigator . serviceWorker . ready
285- const subscription = await registration . pushManager . getSubscription ( )
286-
287- if ( subscription ) {
288- await subscription . unsubscribe ( )
289- }
290-
291301 const { data, error } = await supabaseClient . rpc ( 'unregister_push_subscription' , {
292302 p_device_id : getDeviceId ( )
293303 } )
294304
305+ const registration = await navigator . serviceWorker . getRegistration ( )
306+ const subscription = await registration ?. pushManager . getSubscription ( )
307+ await subscription ?. unsubscribe ( )
308+
295309 if ( error ) {
296310 console . error ( 'Failed to unregister push subscription:' , error )
297311 return false
298312 }
299313
300- clearSubscriptionTimestamp ( )
301-
302314 return data as boolean
303315 } catch ( err ) {
304316 console . error ( 'Failed to unsubscribe from push:' , err )
305317 return false
318+ } finally {
319+ clearSubscriptionTimestamp ( )
306320 }
307321}
308322
@@ -318,18 +332,30 @@ export async function isSubscribed(): Promise<boolean> {
318332 }
319333}
320334
321- function shouldRefreshSubscription ( ) : boolean {
322- const timestamp = localStorage . getItem ( SUBSCRIPTION_TIMESTAMP_KEY )
323- if ( ! timestamp ) return true
324- return Date . now ( ) - parseInt ( timestamp , 10 ) > SUBSCRIPTION_MAX_AGE_MS
335+ // The stamp doubles as the opt-in record: present means this device registered and kept push on.
336+ function readSubscriptionTimestamp ( ) : number | null {
337+ try {
338+ const timestamp = localStorage . getItem ( SUBSCRIPTION_TIMESTAMP_KEY )
339+ return timestamp ? parseInt ( timestamp , 10 ) : null
340+ } catch {
341+ return null
342+ }
325343}
326344
327345function markSubscriptionFresh ( ) : void {
328- localStorage . setItem ( SUBSCRIPTION_TIMESTAMP_KEY , String ( Date . now ( ) ) )
346+ try {
347+ localStorage . setItem ( SUBSCRIPTION_TIMESTAMP_KEY , String ( Date . now ( ) ) )
348+ } catch {
349+ // Storage blocked: the next load saves again, which is harmless.
350+ }
329351}
330352
331353function clearSubscriptionTimestamp ( ) : void {
332- localStorage . removeItem ( SUBSCRIPTION_TIMESTAMP_KEY )
354+ try {
355+ localStorage . removeItem ( SUBSCRIPTION_TIMESTAMP_KEY )
356+ } catch {
357+ // Storage blocked: nothing was stored to clear.
358+ }
333359}
334360
335361async function saveSubscriptionToDatabase ( subscription : PushSubscription ) : Promise < void > {
@@ -353,28 +379,48 @@ async function saveSubscriptionToDatabase(subscription: PushSubscription): Promi
353379 markSubscriptionFresh ( )
354380}
355381
356- export async function refreshSubscriptionIfNeeded ( ) : Promise <
357- 'fresh' | 'refreshed' | 'failed' | 'not_subscribed'
358- > {
359- if ( ! isPushSupported ( ) || ! VAPID_PUBLIC_KEY ) return 'not_subscribed'
382+ type RefreshResult = 'fresh' | 'refreshed' | 'failed' | 'not_subscribed'
383+
384+ let refreshInFlight : Promise < RefreshResult > | null = null
385+
386+ /**
387+ * Load-path sync: re-saves a live subscription daily, restores a lost one, and replaces one
388+ * made with an old VAPID key. It never unsubscribes a working subscription or asks for
389+ * permission. Concurrent callers share one run, so two mounted hooks send one RPC.
390+ */
391+ export function refreshSubscriptionIfNeeded ( ) : Promise < RefreshResult > {
392+ refreshInFlight ??= syncSubscription ( ) . finally ( ( ) => {
393+ refreshInFlight = null
394+ } )
395+ return refreshInFlight
396+ }
397+
398+ async function syncSubscription ( ) : Promise < RefreshResult > {
399+ if ( ! isPushSupported ( ) || ! VAPID_PUBLIC_KEY || Notification . permission !== 'granted' ) {
400+ return 'not_subscribed'
401+ }
360402
361403 try {
362404 const registration = await Promise . race ( [
363405 navigator . serviceWorker . ready ,
364406 new Promise < never > ( ( _ , reject ) => setTimeout ( ( ) => reject ( new Error ( 'timeout' ) ) , 3000 ) )
365407 ] )
366408
367- const existingSubscription = await registration . pushManager . getSubscription ( )
368- if ( ! existingSubscription ) return 'not_subscribed'
369- if ( ! shouldRefreshSubscription ( ) ) return 'fresh'
370-
371- await existingSubscription . unsubscribe ( )
372- const newSubscription = await registration . pushManager . subscribe ( {
373- userVisibleOnly : true ,
374- applicationServerKey : urlBase64ToUint8Array ( VAPID_PUBLIC_KEY )
375- } )
409+ const existing = await registration . pushManager . getSubscription ( )
410+ const savedAt = readSubscriptionTimestamp ( )
411+
412+ // No subscription and no stamp: never opted in here, or turned push off.
413+ if ( ! existing && savedAt === null ) return 'not_subscribed'
414+ if (
415+ existing &&
416+ hasCurrentKey ( existing , VAPID_PUBLIC_KEY ) &&
417+ savedAt !== null &&
418+ Date . now ( ) - savedAt < SUBSCRIPTION_MAX_AGE_MS
419+ ) {
420+ return 'fresh'
421+ }
376422
377- await saveSubscriptionToDatabase ( newSubscription )
423+ await registerPushSubscription ( )
378424 return 'refreshed'
379425 } catch {
380426 return 'failed'
0 commit comments