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

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

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

Что такое Push Notification?


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

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

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



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



Заготовки для магической связки 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() {
      // Для отправки сообщения с payload, подписка должна иметь ключи 'auth' и 'p256dh'.
      webPush.sendNotification({
        endpoint: req.body.endpoint,
        TTL: req.body.ttl,
        keys: {
          p256dh: req.body.key,
          auth: req.body.authSecret
        }
      }, 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;
var key;
var authSecret;

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) {
  // Получаем public key для пользователя.
  var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '';
  key = rawKey
      ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey)))
      : '';
  var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : '';
  authSecret = rawAuthSecret
      ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret)))
      : '';

  endpoint = subscription.endpoint;

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

// Для демонстрации функционала.
// Данный код на "Боевых" приложениях не нужен, т.к. генерация уведомлений всегда происходит на сервере.
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,
      key: key,
      authSecret: authSecret
    }),
  });
};


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-приложений и способы ускорения. Будет полезно?