Service Workers. Web Push и где они обитают

zoni-a_2z1k3ue5ht4jalzyhowm.jpeg
Добро пожаловать в школу CODEдейства и волшебства!

На сегодняшнем занятии мы с вами узнаем, как использовать малоизвестный тандем Web Push + Service Workers (SW). Я приоткрою вам завесу: расскажу о способе удерживать аудиторию маглов благодаря технологии Web Push и о том, чем это может быть полезно для редакций сайтов и прочих интернет-сервисов.

Текст продолжает статью о базовом применении SW. Из комментариев и вопросов я понял, что людям интересно направление SW + Web Push. Давайте вместе разберёмся с тем, что это такое, и как использовать эту магическую пару на практике.

Что такое Push Notification?


Вы принимаете оповещения на свою электронную почту: заходите в почтовый клиент и смотрите входящие письма. В данном случае это технология pull, то есть вы заходите на сайт и «тянете» с него данные, когда они вам нужны.

В случае же с push-уведомлениями ресурс проталкивает новые данные вам сам. При этом вы сразу получаете самые свежие данные, ведь в этой технологии нет определённого периода проверки данных, они приходят в режиме онлайн. Использование пушей не ограничивается получением уведомлений. Так, через push-технологию можно синхронизировать данные при обновлении.

Push-уведомления — это небольшие всплывающие окна. Они могут появляться на экране, где есть область оповещений или есть возможность вывода на экран принятых данных. Push-уведомления работают даже тогда, когда пользователь ушёл с сайта. Это означает, что вы можете доставлять сообщения о новых материалах и новых событиях, чтобы привлечь внимание пользователя в ваше приложение.

5gcesq96gfrgajoc7keb5m41kzy.png

Не забывайте о поддержке в браузерах. Так в IE, Edge и Safari ваши Push-уведомления работать не будут.

fugrv7jyigwpu9wzkwtsrorzc2a.png

Заготовки для магической связки SW и Web-push


Для написания своего собственного SW для работы с Web-push нам как всегда понадобятся:

  • index.html
  • index.js
  • sw.js
  • server.js (будем использовать express и библиотеку web-push для простоты)


Всё, что нужно сделать, это в index.html подключить index.js, в котором будет происходить регистрация файла sw.js.
В файле server.js я укажу лишь эндпоинт (точка входа в API серверного приложения) для регистрации push-уведомлений.

Пример кода файла server.js
// Мы используем библиотеку web-push, чтобы скрыть детали реализации связи
// между сервером приложений и службой push.
// Для получения дополнительной информации 
// см. https://tools.ietf.org/html/draft-ietf-webpush-protocol 
// и https://tools.ietf.org/html/draft-ietf-webpush-encryption.

var webPush = require('web-push');

// Про GCM_API_KEY вы можете подробнее узнать из
// https://developers.google.com/cloud-messaging/
webPush.setGCMAPIKey(process.env.GCM_API_KEY || null);
// В данном примере мы будем рассматривать только route'ы в express.js
module.exports = function(app, route) {
  app.post(route + 'register', function(req, res) {
    res.sendStatus(201);
  });

  app.post(route + 'sendNotification', function(req, res) {
    setTimeout(function() {
      webPush.sendNotification({
        endpoint: req.body.endpoint,
        TTL: req.body.ttl,
      }, req.body.payload)
      .then(function() {
        res.sendStatus(201);
      })
      .catch(function(error) {
        res.sendStatus(500);
        console.log(error);
      });
    }, req.query.delay * 1000);
  });
};


В статье мы рассмотрим варианты отправки push-уведомлений и способы их применения. Давайте знакомиться с магией вне Хогвартса вместе.

Push Payload


Этот простейшее магическое заклинание показывает, как отправлять и получать строки, но данные могут быть извлечены из push-сообщения в различных форматах: строки, буфер ArrayBuffer, BLOB-объект в JSON.

Способ применения

Если вам просто нужны push-уведомления — этот пример для вас. Сообщение может доставлять не только текст, но и payload — обогащенные данные для приложения. Код ниже демонстрирует, как вы можете доставлять payload для вашего приложения.

Для демонстрации мы используем данные из текстового поля, которые будут отправлены на сервер и затем отображены в виде push-уведомления через SW.

index.js
var endpoint;

navigator.serviceWorker.register('service-worker.js')
.then(function(registration) {
  // Используем PushManager, чтобы получить подписку пользователя из пуш-сервиса. 
  return registration.pushManager.getSubscription()
  .then(function(subscription) {
    // Если подписка уже существует возвращаем ее.
    if (subscription) {
      return subscription;
    }
    // В противном случае, подписываем пользователя.
    // userVisibleOnly - это флаг указывающий, что возвращенная push-подписка 
    // будет использоваться только для сообщений, 
    // эффект которых будет виден для пользователя.
    return registration.pushManager.subscribe({ userVisibleOnly: true });
  });
}).then(function(subscription) {
  endpoint = subscription.endpoint;

  // Отправляем детали о подписке на сервер используя Fetch API
  fetch('./register', {
    method: 'post',
    headers: {
      'Content-type': 'application/json'
    },
    body: JSON.stringify({
      endpoint: subscription.endpoint,
    }),
  });
});

// Для демонстрации функционала.
// Данный код на "Боевых" приложениях не нужен, т.к. генерация уведомлений всегда происходит на сервере.
document.getElementById('doIt').onclick = function() {
  var payload = document.getElementById('notification-payload').value;
  var delay = document.getElementById('notification-delay').value;
  var ttl = document.getElementById('notification-ttl').value;
 
  fetch('./sendNotification', {
    method: 'post',
    headers: {
      'Content-type': 'application/json'
    },
    body: JSON.stringify({
      endpoint: endpoint,
      payload: payload,
      delay: delay,
      ttl: ttl,
    }),
  });
};


service-worker.js
// Регистрируем функцию на событие 'push'
self.addEventListener('push', function(event) {
  var payload = event.data ? event.data.text() : 'Alohomora';
  
  event.waitUntil(
    // Показываем уведомление с заголовком и телом сообщения.
    self.registration.showNotification('My first spell', {
      body: payload,
    })
  );
});

Rich Notifications


Усложним предыдущий вариант и добавим спецэффектов, здесь всё зависит от ваших желаний и фантазии. Нам поможет полное Notification API. API предоставляет интерфейс для использования «живых» push-уведомлений пользователю c указанием локали, шаблоном вибрации, изображения.

Способ применения

Пример схож с тем, что описан выше, но позволяет использовать более расширенное Notification API, чтобы выбирать изображение, выставлять локаль и шаблон уведомления — то есть делать уведомление уникальным.

service-worker.js
// Ключевое отличие по сравнению с Push Payload именно в использовании
// Notitfication API в SW

self.addEventListener('push', function(event) {
  var payload = event.data 
    // У нас всё-таки волшебный мир с фантастическими тварями, поэтому
    // try.. catch мы не ставим ¯\_(ツ)_/¯
    ? JSON.parse(event.data)
    : {
      name: 'Expecto patronum!',
      icon: 'buck.jpg',
      locale: 'en'
    };
  
  event.waitUntil(
    // Показываем уведомление с заголовком и телом сообщения.
    // Устанавливаем иные параметры:
    // * язык
    // * шаблон вибрации
    // * изображение
    // имеется очень много параметров, о которых вы можете узнать тут
    // https://notifications.spec.whatwg.org/
    self.registration.showNotification('Summoning spell', {
      lang: payload.locale,
      body: payload.name,
      icon: payload.icon,
      vibrate: [500, 100, 500],
    })
  );
});

Push Tag


Следующее заклинание будет демонстрировать аккумулирование и замену предыдущего заклинания более сильным. Используем тег для уведомления, чтобы заменить старые уведомления новыми. Позволяет показывать маглам только актуальную информацию или сворачивать несколько уведомлений в одно.

Способ применения

Вариант подойдёт для тех приложений, где имеются чаты или уведомления о новом контенте (примеры: Tproger и Tinder). Ниже код демонстрирует, как управлять очередью уведомлений, чтобы предыдущие уведомления можно было отбросить или объединить в одно уведомление. Это полезно, чтобы иметь fallback на случай, если мы написали чат, где можно редактировать сообщения. Клиент увидит не тонну уведомлений с исправлениями, а всего лишь одно.

service-worker.js
var num = 1;

self.addEventListener('push', function(event) {
  event.waitUntil(
    // Показываем уведомление с заголовком и телом сообщения.
    // Число, которое увеличивается для каждого полученного уведомления.
    // Поле тега позволяет заменить старое уведомление на новое 
    // (уведомление с тем же тегом другого заменит его)
    self.registration.showNotification('Attacking Spell', {
      body: ++num > 1 ? 'Bombarda Maxima' : 'Bombarda',
      tag: 'spell',
    })
  );
});

Push Clients


Пришло время для «непростительных заклинаний». Напомню, почему они непростительные:
любое чрезмерное применение этих заклинаний к маглам карается пожизненным заключением в Азкабан. Поэтому главное — не надоедать!

Когда магл нажмет на уведомление, сгенерированное из push-события, оно сфокусирует его на вкладке приложения или даже повторно откроет его, если оно было закрыто.

Способ применения

Ниже код для трёх случаев использования доставки уведомлений в зависимости от состояния приложения.

Он может распознавать, когда вы находитесь на странице или нужно переключиться на открытую вкладку или повторно открыть вкладку.

Способ поможет вам увеличить конверсию приложения, если вы хотите вернуть магла. Только не перегибайте палку, маглам может не понравиться излишнее внимание приложения.

Самые классические примеры использования:

  • пришло сообщение в чат (Tinder),
  • интересная новость (Tproger),
  • обновилась задача в баг-трекере,
  • успешно/неуспешно прошел CI перед релизом,
  • клиент оплатил заказ/заключил сделку (будь то интернет-магазин или CRM).


Во всех этих случаях при клике в push клиенту откроется наше приложение или он будет сфокусирован уже на открытой вкладке.

service-worker.js
self.addEventListener('install', function(event) {
  event.waitUntil(self.skipWaiting());
});

self.addEventListener('activate', function(event) {
  event.waitUntil(self.clients.claim());
});

self.addEventListener('push', function(event) {
  event.waitUntil(
    // Получить список клиентов для SW
    self.clients.matchAll().then(function(clientList) {
          // Проверяем, есть ли хотя бы один сфокусированный клиент.
      var focused = clientList.some(function(client) {
        return client.focused;
      });

      var notificationMessage;
      if (focused) {
        notificationMessage = 'Imperio! You\'re still here, thanks!';
      } else if (clientList.length > 0) {
        notificationMessage = 'Imperio! You haven\'t closed the page, ' +
                              'click here to focus it!';
      } else {
        notificationMessage = 'Imperio! You have closed the page, ' +
                              'click here to re-open it!';
      }
      // Показывать уведомление с заголовком «Unforgiveable Curses»
      // и телом в зависимости от состоянию клиентов SW
      // (три разных тела: 
      // * 1, страница сфокусирована;
      // * 2, страница по-прежнему открыта, но не сфокусирована;
      // * 3, страница закрыта).
      return self.registration.showNotification('Unforgiveable Curses', {
        body: notificationMessage,
      });
    })
  );
});

// Регистрируем обработчик события 'notificationclick'.
self.addEventListener('notificationclick', function(event) {
  event.waitUntil(
    // Получаем список клиентов SW.
    self.clients.matchAll().then(function(clientList) {
      // Если есть хотя бы один клиент, фокусируем его.
      if (clientList.length > 0) {
        return clientList[0].focus();
      }
      // В противном случае открываем новую страницу.
      return self.clients.openWindow('our/url/page');
    })
  );
});

Push Subscription


Пришло время завладеть разумом наших маглов. Маглы называют это «телепатией» или чтением мыслей, но будем делать иначе. Давайте научимся помещать нашу информацию и заставлять привязываться к нашему приложению. Этот пример показывает, как использовать push-уведомления с управлением подпиской, позволяя пользователям подписаться на приложение и поддерживать связь с ним. Стараемся помнить об Азкабане!

Способ применения

После того, как SW зарегистрирован, клиент проверяет, подписан ли он на сервис уведомлений. В зависимости от этого устанавливается текст кнопки.

После успешной подписки (index.js: pushManager.subscribe) клиент отправляет post-запрос на сервер приложений для регистрации подписки.

Сервер периодически отправляет уведомление с помощью библиотеки web-push на все зарегистрированные эндпоинты. Если эндпоинт больше не зарегистрирован (подписка истекла или отменена), текущая подписка удаляется из списка подписок.

После успешной отписки (index.js: pushSubscription.unsubscribe) клиент отправляет post-запрос на сервер приложений, чтобы отменить регистрацию подписки. Сервер больше не отправляет уведомления. SW также следит за событиями pushsubscriptionchange и resubscribes.

Подписка может быть отменена пользователем вне страницы в настройках браузера или UI-уведомлений. В этом случае бекенд перестанет отправлять уведомления, но фронтенд об этом не узнает. Чтобы магия работала, важно периодически проверять, активна ли регистрация в службе уведомлений.

index.js
// Для простоты будем использовать кнопку. 
// На боевой версии лучше использовать события.
var subscriptionButton = document.getElementById('subscriptionButton');

// Поскольку объект подписки требуется в нескольких местах, давайте создадим метод,
// который возвращает Promise.
function getSubscription() {
  return navigator.serviceWorker.ready
    .then(function(registration) {
      return registration.pushManager.getSubscription();
    });
}

if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('service-worker.js')
    .then(function() {
      console.log('SW registered');
      subscriptionButton.removeAttribute('disabled');
    });
  getSubscription()
    .then(function(subscription) {
      if (subscription) {
        console.log('Already invaded', subscription.endpoint);
        setUnsubscribeButton();
      } else {
        setSubscribeButton();
      }
    });
}

// Получить «registration» от SW и создать новую
// подписку с помощью `registration.pushManager.subscribe`.
// Затем зарегистрировать новую подписку, отправив POST-запрос.
function subscribe() {
  navigator.serviceWorker.ready.then(function(registration) {
    return registration.pushManager.subscribe({ userVisibleOnly: true });
  }).then(function(subscription) {
    console.log('Legilimens!', subscription.endpoint);
    return fetch('register', {
      method: 'post',
      headers: {
        'Content-type': 'application/json'
      },
      body: JSON.stringify({
        endpoint: subscription.endpoint
      })
    });
  }).then(setUnsubscribeButton);
}

// Получить существующую подписку от SW,
// отменить подписку (`subscription.unsubscribe ()`) и 
// отменить регистрацию на сервере с помощью POST-запроса 
// для прекращения отправки push-сообщений.
function unsubscribe() {
  getSubscription().then(function(subscription) {
    return subscription.unsubscribe()
      .then(function() {
        console.log('Unsubscribed', subscription.endpoint);
        return fetch('unregister', {
          method: 'post',
          headers: {
            'Content-type': 'application/json'
          },
          body: JSON.stringify({
            endpoint: subscription.endpoint
          })
        });
      });
  }).then(setSubscribeButton);
}

// Для демонстрации (или тренировок). Изменяем текст кнопки.
function setSubscribeButton() {
  subscriptionButton.onclick = subscribe;
  subscriptionButton.textContent = 'Open mind!';
}

function setUnsubscribeButton() {
  subscriptionButton.onclick = unsubscribe;
  subscriptionButton.textContent = 'Protego!';
}

service-worker.js
// Слушаем событие 'push'.
self.addEventListener('push', function(event) {
  event.waitUntil(self.registration.showNotification('Your mind', {
    body: 'Wizard invaded to your mind!'
  }));
});

// Слушаем событие 'pushsubscriptionchange', которое запускается,
// когда истекает срок подписки. 
// Подписываемся снова и регистрируем новую подписку на сервере,
// отправив POST-запрос.
// На боевом приложении скорее всего будет использоваться ID или token
// для идентификации пользователя.
self.addEventListener('pushsubscriptionchange', function(event) {
  console.log('Spell expired');
  event.waitUntil(
    self.registration.pushManager.subscribe({ userVisibleOnly: true })
    .then(function(subscription) {
      console.log('Another invade! Legilimens!', subscription.endpoint);
      return fetch('register', {
        method: 'post',
        headers: {
          'Content-type': 'application/json'
        },
        body: JSON.stringify({
          endpoint: subscription.endpoint
        })
      });
    })
  );
});

server.js
var webPush = require('web-push');
var subscriptions = [];
var pushInterval = 10;

webPush.setGCMAPIKey(process.env.GCM_API_KEY || null);

// Отправляем уведомление push-сервису. 
// Удаляем подписку из общего массива `subscriptions`,
// если push-сервис отвечает на ошибку или подписка отменена или истекла.
function sendNotification(endpoint) {
  webPush.sendNotification({
    endpoint: endpoint
  }).then(function() {
  }).catch(function() {
    subscriptions.splice(subscriptions.indexOf(endpoint), 1);
  });
}

// В реальных условиях приложение отправляет уведовление только в случае
// возникновения события.
// Чтобы имитировать его, сервер отправляет уведомление каждые `pushInterval` секунд
// каждому подписчику
setInterval(function() {
  subscriptions.forEach(sendNotification);
}, pushInterval * 1000);

function isSubscribed(endpoint) {
  return (subscriptions.indexOf(endpoint) >= 0);
}

module.exports = function(app, route) {
  app.post(route + 'register', function(req, res) {
    var endpoint = req.body.endpoint;
    if (!isSubscribed(endpoint)) {
      console.log('We invaded into mind ' + endpoint);
      subscriptions.push(endpoint);
    }
    res.type('js').send('{"success":true}');
  });

  // Unregister a subscription by removing it from the `subscriptions` array
  app.post(route + 'unregister', function(req, res) {
    var endpoint = req.body.endpoint;
    if (isSubscribed(endpoint)) {
      console.log('It was counterspell from ' + endpoint);
      subscriptions.splice(subscriptions.indexOf(endpoint), 1);
    }
    res.type('js').send('{"success":true}');
  });
};

Ещё разок о заклинаниях


Выше мы рассмотрели магические способы использования SW и Web Push для приложений.
Данный тандем таит в себе множество интересных применений.

Если вам нужно лишь иногда зазывать магла к себе в приложение или сообщать ему о об исправлениях или изменении статуса его заказа, то используйте Push Payload. Можно добавить немного фантазии и заиспользовать Notification API — тогда цвета и иконка вашего приложения будет видны пользователю в Rich Push.

Если же вы желаете завладеть всем вниманием магла и установить с ним контакт — примеры Push Client и Push Subscription для вас. Главное — помните об Азкабане, иначе вы начнёте терять свою аудиторию.

Жду ваших комментариев и пожеланий на следующую тему. От себя добавлю, что хотелось бы поговорить и обсудить тему работы SW + React/Redux-приложений и способы ускорения. Будет полезно?

© Habrahabr.ru