Привет, Хабр! Меня зовут Оля, я ведущий программист в отделе разработки интерфейсов ЮMoney. Работаю в команде «Портал» и занимаюсь главной страницей, страницами настроек, онлайн-оплаты и аналитики расходов.
В 2022 году мы, как и многие компании, столкнулись с удалением своих приложений из сторов и искали альтернативные решения. Одним из вариантов было сделать своё прогрессивное веб-приложение (Progressive Web App, PWA).
Всю вторую половину 2023 года мы превращали наш сайт в PWA-приложение, чтобы им было удобно пользоваться на устройствах iOS. Рассказываю, что из этого получилось и какие у нас планы на будущее. ?

Что такое PWA
Википедия определяет PWA как технологию в веб-разработке, которая визуально и функционально трансформирует сайт в приложение. А так её видит ChatGPT:

Я же определяю PWA как набор технологий, которые позволяют превратить приложение в нечто нативное. Вот какие это могут быть технологии:
Push API,
Web App Manifest,
Service Worker,
Notifications API,
Shape Detection API,
JavaScript, CSS, HTML и другие веб-технологии.
Мы обратились к PWA в первую очередь для того, чтобы восстановить пуш-уведомления у наших пользователей на iOS. Поэтому опыт, которым я сегодня поделюсь, будет больше актуален для этой операционной системы. Но технологии, которые рассмотрю, — кросс-браузерные, они работают и на десктопе, и на iOS, и на Android.
Как работают пуш-уведомления в PWA ЮMoney
Когда мы хотим подписать пользователя на пуш-сообщения, он заходит на сайт и видит предложение подписаться на уведомления от нас.
Если пользователь даёт разрешение на подписку, мы идём на наш сервер и запрашиваем генерацию специального ключа. Сервер отдаёт нам публичную часть этого ключа.
Дальше идём с этим ключом в службу пуш-сообщений, где создаётся подписка. Подписка возвращается обратно в браузер, и мы сохраняем этот объект у себя в бэкенде для дальнейшего использования.
Объект подписки выглядит как JSON-файл, в котором есть два поля: endpoint и keys (набор ключей, которые будут использоваться для дальнейшего шифрования пушей).
У каждого браузера есть служба пуш-сообщений. Например, у браузеров Chrome это Firebase, у Firefox — Mozilla Push Service, а у Apple Safari — Apple Push Notification service (APNs).
В момент, когда мы хотим отправить пользователю сообщение, наш бэкенд, используя данные подписки, отправляет его в службу пуш-сообщений, а служба — на устройство пользователя.
Технология Push API не нова. В браузерах, подобных Chrome, она появилась ещё в 2015 году, но в Safari — только недавно, в 2022-м, а в Mobile Safari — в 2023-м. В Mobile Safari она работает только в том случае, если пользователь добавил приложение на экран «Домой». Поэтому наша главная задача — подтолкнуть пользователя добавить сайт на экран. В этом помогает всплывающее окно с инструкцией:

После того как пользователь добавляет приложение, нам нужно сделать шесть шагов, чтобы пуши заработали:
1. Подключить манифест.
2. Зарегистрировать Service Worker.
3. Запросить разрешение на получение уведомлений.
4. Получить VAPID-ключ от бэкенда.
5. Создать подписку.
6. Сохранить её данные на бэкенде.
Рассмотрим технологии, используемые в этих шести шагах
1) WebAppManifest. Это JSON-файл с информацией о нашем приложении. В нём можно настроить различные параметры того, как будет выглядеть веб-приложение: цвет фона (background_color), иконка (icons), название приложения (name), стартовый URL, который будет открываться при запуске. Можно также добавить, например, UTM-метки для статистики.

И самое главное — это свойство display, определяющее предпочитаемый браузером вид сайта. Свойство может принимать такие значения, как browser (когда есть элементы управления браузером) и fullscreen (когда элементов управления браузером нет, как и иконок операционной системы). Мы в ЮMoney используем standalone, когда нет элементов управления браузером, но есть иконки операционной системы.
Вот так может выглядеть файл манифеста:
{
"name": "ЮMoney",
"short_name": "ЮMoney",
"display": "standalone",
"scope": "/",
"start_url": "/main?utm_source=pwa&utm_medium=direct",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"icons": [
{
"src": "https://static.yoomoney.ru/files-front/resources/head/wallet/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "https://static.yoomoney.ru/files-front/resources/head/wallet/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
Подключение манифеста:
<head>
...
<link rel="manifest" href="/site.webmanifest">...
</head>
Подключить манифест нужно в общем месте для страниц сайта, чтобы он распространял своё действие на все страницы.
Тут мы столкнулись с проблемой: статические ресурсы, такие как манифест, хранятся у нас на поддомене. Нам пришлось прибегнуть к магии nginx и использовать redirect.
2) Service Worker. Это событийно-управляемый воркер, который представляет собой JavaScript-файл, запускаемый браузером в фоновом режиме. Он обеспечивает кэширование ресурсов для работы в офлайн-режиме, фоновую синхронизацию данных, а также (самое главное для нас) — получение и отправку пуш-уведомлений.
Для работы с Service Worker характерны некоторые особенности:
Нет прямого доступа к DOM — взаимодействие с ним происходит через postMessage API.
Он запускается в воркер-контексте, в отдельном потоке, не блокируя основной.
Он полностью асинхронный — там нельзя использовать синхронный API.
Он может перехватывать сетевые запросы, поэтому работает только по HTTPS или локально в целях разработки.
Для начала работы необходимо зарегистрировать Service Worker. Проверяем его доступность в нашем окружении. Если он доступен, то регистрируем его по адресу, передаваемому в метод регистрации. Важно обращать внимание на расположение файла сервис-воркера, потому что этим будет обусловлена его область видимости.
Регистрация Service worker:
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}
try {
// нет необходимости проверять зарегистрирован ли он уже
const registration = await navigator.serviceWorker.register('/service-worker.js');
return registration;
} catch (error) {
return;
}
В этом примере мы подключаем файл, расположенный в корне домена. Он будет распространять своё действие на весь домен.
Здесь мы столкнулись с проблемой, что его нужно хранить на поддомене и нет возможности использовать redirect nginx. Но вместо redirect можно применить rewrite.
В сервис-воркере можно подписаться на множество событий — нас интересуют два: получение пуша и клик на нотификацию.
Пример подписки на события. Так может выглядеть код сервис-воркера, где мы обрабатываем эти два события:
self.addEventListener('push', (event) => {
const notification = event.data.json();
event.waitUntil(
self.registration.showNotification(
notification.title,
{body: notification.body, data: notification.data},
)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
При наступлении пуша мы показываем нотификацию с помощью метода showNotification.
Во время клика на нотификацию мы открываем URL, который пришёл в данных пуша.
Здесь следует обратить внимание на конструкцию waitUntil. У разработчика мало влияния на то, когда сервис-воркер запускается и останавливается, браузер решает это сам. Этой конструкцией мы говорим, что сервис-воркер занимается важной обработкой и что, пока промис (специальный объект в JavaScript, который используется для написания и обработки асинхронного кода) не завершится, сервис-воркер останавливать не нужно.
3) Notifications API. С помощью этой технологии можно контролировать отображение системных уведомлений, даже если приложение неактивно, пользователь его свернул или переключился на другую вкладку. На этом этапе наша главная задача — инициировать браузером запрос, чтобы пользователь разрешил показывать ему уведомления. Это мы делаем с помощью метода requestPermission.
Safari неверно обрабатывает ответ запроса на получение уведомлений, если этот запрос инициирован не каким-то конкретным действием пользователя. Поэтому нам пришлось добавить всплывающее окно с одной кнопкой, нажав на которую, пользователь может разрешить или запретить отправку уведомлений.

Состояние разрешения может быть в трёх статусах:
Default — когда мы ничего не запрашивали и можем запросить, а пользователь разрешит или запретит.
Denied — когда мы запрашивали разрешение, а пользователь нам отказал. В этом случае мы не можем ничего сделать. Но у пользователя будет возможность самостоятельно поставить разрешение в настройках своего устройства.
Granted — когда разрешение на отправку уведомлений получено, мы можем дальше создавать подписку. Для этого нам понадобится VAPID-ключ.
4) VAPID-ключ. VAPID — это добровольная идентификация сервера приложений (Voluntary Application Server Identification). Ключ передаётся службе пуш-сообщений и используется для проверки, что отправляющий пуши сервер — это тот же сервер, на пуши которого подписался пользователь. Бэкенд отдаёт нам публичный ключ, с которым мы создаём подписку на устройстве пользователя.
VAPID используется в момент, когда нам нужно отправить сообщения. Наш бэкенд подписывает свои сообщения приватным ключом, потом в POST-заголовке отправляет эту информацию в службу пуш-сообщений, а она с помощью публичного ключа подтверждает, что это сообщение подписано приватным ключом из той же пары, и отвечает нашему сервису, что сообщения приняты к доставке. Затем служба отправляет пуш-сообщение на устройство пользователя.

Чтобы получить VAPID-ключ, мы используем fetch API, потому что любой браузер, который поддерживает сервис-воркеры, поддерживает и fetch API.
5) Push API. Даёт возможность получать сообщения с сервера независимо от того, запущено приложение или нет. Среди методов Push API нас интересуют три:
Создание подписки. Вызываем метод subscribe у инстанса pushManager регистрации сервис-воркера и передаём туда два параметра. Первый — userVisibleOnly, он должен быть всегда true и говорить о том, что сообщение, которое мы получаем, будет видно пользователю. False сейчас не поддерживает ни один браузер. Второй параметр — applicationServerKey, тот самый публичный ключ, полученный от нашего бэкенда.
const registration = await navigator.serviceWorker.register('/service-worker.js');
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID.publicKey
});
Получение подписки. Метод может понадобиться, если нужно проверить, есть ли уже подписка на устройстве пользователя и нужно ли создавать новую. Или если мы хотим получить какие-то данные из подписки, например endpoint или ключи.
Отмена подписки. Понадобится, если нужно отписать пользователя — например, когда он разлогинится. У сервис-воркера нет знания о сессиях пользователя, и этим нужно управлять вручную.
6) Сохранение подписки на бэкенд. Финальный шаг из нашего списка. Используем fetch API и передаём на бэкенд данные подписки.
В итоге мы выполнили все шесть шагов:
Подключили манифест.
Подключили сервис-воркер.
Запросили у пользователя разрешение на отправку уведомлений.
Запросили и получили у бэкенда VAPID-ключ.
Создали подписку.
Cохранили её на бэкенде.
Теперь сервер может отправлять, а устройство — получать пуш-уведомления.

После запуска пуш-сообщений мы проанализировали, как пользователи применяют наш PWA. Выяснилось, что 75% посетителей сайта ЮMoney заходят на него с мобильных устройств и что более 35% пользователей iOS делают это через PWA. Эта цифра постоянно растёт. Команда нашего B2B-направления (ЮKassa) тоже подключила в свой сервис пуш-сообщения.

В итоге за первые 30 дней после запуска PWA мы отправили пользователям ЮMoney и ЮKassa около миллиона пуш-уведомлений.
Также мы добавили в наше приложение сканирование QR-кодов для быстрой оплаты на кассе. К сожалению, Safari пока не поддерживает Shape Detection API и Barcode Detection API, поэтому мы используем альтернативные библиотеки.

Считаем запуск PWA для iOS удачным и планируем cделать то же самое для Android-устройств.

А я так впечатлилась разработкой нашего PWA, что сделала собственный сервис уведомлений для любителей настолок.
Совместила, так сказать, два своих хобби! =)

Мой сервис отправляет пуш-уведомления о том, когда будет следующая игра. Попросила подписаться всех своих друзей, с которыми мы играем, чтобы никто не пропускал встречи. ?
Если у вас остались вопросы о PWA в ЮMoney (или о моей разработке для любителей настольных игр), с удовольствием отвечу на них в комментариях. ?
Комментарии (5)
slbeat
07.02.2025 14:39Спасибо! Благодаря вашей статье узнал что пуши работают на iOS в PWA. Раньше было стойкое знание что это невозможно.
Also_known_as
07.02.2025 14:39Шёл 25й год , вап девелоперы из яндекса удивлялись pwa и внедряли прорывные технологии. Спасибо тиму коку что спустя 8 лет в сафари появился сервис воркер и пуши, это почти так же продвинуто как внедрение пва в 25ом
Negat1v9
Yoomoney странная платформа, у меня не сложилось поработать с ними. После регистрации как самозанятый, при попытке авторизоваться, все браузеры блокируют сайт, говорят, что «не возможно открыть русском из-за циклических переадресаций»
YooMoneyHelp
Здравствуйте! Сайт работает штатно. Попробуйте, пожалуйста, очистить сookie и кеш браузера и отключить расширения (VPN, например). Также попробуйте войти с другого браузера (Яндекс, например) и/или подключиться к другому интернет-соединению. Если трудности возникнут снова, напишите нам в лс.