Manejando suscripciones push en el navegador

Mar 22, 2019

La especificación del W3 define un evento pushsubscriptionchanges para renovar la suscripción push en el navegador cuando expira y enviar una nueva al servidor. Pero navegadores como Chrome, no están siguiendo el estándar y no implementan este evento todavía. ¿Qué deberíamos hacer cuando la suscripción caduca si no tenemos el evento descrito? Esto el lo que vamos a ver a continuación.

Imagen de notificación push

El problema

La interfaz del evento es la siguiente:

interface PushSubscriptionChangeEvent extends ExtendableEvent {
   readonly newSubscription?: PushSubscription;
   readonly oldSubscription?: PushSubscription;
}

Para usar este evento, tenemos que añadir un listener en el service worker:

self.addEventListener('pushsubscriptionchange', (event: PushSubscriptionChangeEvent) => {
   //Renew your subscription here.
});

Hasta este punto parece que todo es normal y sencillo. Nosotros hacemos una request al servidor para actualizar la suscripción y parece que el problema estaría resuelto.

async handlePush(event: PushSubscriptionChangeEvent) {
  const req = new Request('/refreshpushsubscription', {
    method: 'POST',
    body: JSON.stringify(
        {oldSubscription: event.oldSubscription, newSubscription: event.newSubscription})
  });
  const response = await self.fetch(req);
}

self.addEventListener('pushsubscriptionchange', (event: PushSubscriptionChangeEvent) => 
                      event.waitUntil(handlePush(event)));

El problema es que el código anterior no produce ningún efecto en la mayoría de los navegadores. oldSubscription y newSubscription son siempre null. Esto plantea las siguiente cuestiones:

  • Si newSubscription es null, ¿cómo la obtengo?
  • Si oldSubscription es null, ¿cómo puedo hacer para que server pueda saber qué suscripción tiene que actualizar?

La solución

Podemos solucionar el primero de los problemas llamando a la función getSubscription(). Pero para solucionar el segundo, necesitamos almacenar la suscripción antigua en una IndexedDb para que cuando se lance pushsubscriptionchange, podamos enviarle al servidor ámbas.

El código resultante sería el siguiente:

// To use IndexedDB like localStorage use this library.
// https://github.com/RyotaSugawara/serviceworker-storage
const storage = new ServiceWorkerStorage('sw:storage', 1);

async handlePush() {
  const newSubscription = await self.registration.pushManager.getSubscription();
  const oldSubscription = JSON.parse(await storage.getItem('subscription'));
  if(newSubscription.endpoint !== oldSubscription.endpoint) {
    storage.setItem('subscription', newSubscription);
    const req = new Request('/refreshpushsubscription', {
    method: 'POST',
    body: JSON.stringify(
        {oldSubscription: oldSubscription, newSubscription: newSubscription})
    });
    const response = await self.fetch(req);
  }

}

async savePush(subscription: PushSubscription) {
  await storage.setItem('subscription', subscription);
}

self.addEventListener('pushsubscriptionchange', (event: PushSubscriptionChangeEvent) => 
                      event.waitUntil(handlePush()));

// Listen messages from browser and wait for the first subscription to save it for
// future expirations.
self.addEventListener('message', (event) => {
  if(event.data.action === 'REQUEST_SUBSCRIPTION') {
     event.waitUntil(savePush(event.data.subscription));
  }
});

La primera vez, solicitamos una suscripción y el usuario otorga permisos en el navegador, por lo que debemos enviar un mensaje al service worker con la acción REQUEST_SUBSCRIPTION y éste guardará la suscripción en la IndexedDb. Si la suscripción expira, el service worker podrá actualizarla en el servidor sin problemas a traves del evento pushsubscriptionchange.

En el navegador podemos hacer algo tal que así:

navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
  const options = {userVisibleOnly: true, applicationServerKey: applicationServerKey};
  serviceWorkerRegistration.pushManager.subscribe(options).then(pushSubscription => {
    // Send subscription to service worker to save it.
    navigator.serviceWorker.controller.postMessage({
      action: 'REQUEST_SUBSCRIPTION',
      subscription: pushSubscription
    });
    // Upload first subscription to server using userId or any identifier
    // to match user.
    uploadSubscriptionToTheServer(pushSubscription, userId);
  });
});

¿Qué ocurre con Chrome?

Como hemos dicho al inicio del artículo, Chrome no implementa el evento pushsubscriptionchange todavía, esto significa que aunque lo escuchemos no se va a lanzar nunca, entonces, ¿Cómo podemos solucionar esto?

  • Hasta el momento la única forma es llamar a uploadSubscriptionToTheServer() cada vez que se carga la página para minimizar el tiempo de suscripción inválida.
  • Si la página se puede cargar sin conexión, entonces es posible usar la API de background sync para actualizar la suscripción cuando el navegador esté en el línea de nuevo.

Esta solución no es perfecta, el usuario podría perder algunas notificaciones push si no abre la webapp por mucho tiempo.