Мне понадобились push-уведомления в вебе - и принципиально без Firebase, OneSignal и прочих посредников. Хотелось полный контроль над логикой и ноль внешних зависимостей, за которые потом расплачиваешься чужими лимитами и приватностью пользователей.
Хорошая новость: весь нужный стек давно лежит в самом браузере - Service Worker, Push API, VAPID и Notifications API. Плохая (точнее, бывшая плохая): iOS, из-за которого эту задачу все годами откладывали. Apple добавила web-push только в марте 2023-го, и то с оговорками. Их мы тоже разберём и обойдём.
Дальше - весь путь от нуля до рабочих уведомлений, которые приходят даже при закрытом браузере, с кодом, который можно копировать. Разберём не только «как», но и «почему так, а не иначе», и где вас ждут грабли в проде.
Уровень: нужно понимать async/await, fetch и в общих чертах представлять, что такое PWA.
Как это вообще работает
Push в вебе держится на четырёх кусках, и по отдельности ни один не работает. Прежде чем лезть в код, полезно увидеть весь путь. Он распадается на две фазы.
Фаза 1 - подписка (происходит один раз):
Пользователь (разрешает уведомления) → Браузер (запрашивает подписку у push-сервиса) → Push-сервис (выдаёт endpoint + ключи шифрования) → Ваш сервер (получает и сохраняет подписку)
Фаза 2 - отправка (каждый раз, когда есть что сообщить):
Ваш сервер (шлёт сообщение, подписанное VAPID) → Push-сервис (доставляет) → Браузер (будит воркер) → Service Worker (событие 'push') → Уведомление (showNotification)
Теперь по каждому участнику коротко - без воды, только то, что реально влияет на код.
Service Worker - JS-скрипт, который живёт в фоне отдельно от страницы, без доступа к DOM, и продолжает получать события даже когда все вкладки закрыты. Именно он ловит push и рисует уведомление. Работает только по HTTPS (исключение - localhost для разработки).
Push API - то, чем браузер создаёт подписку и через что сообщение доезжает до устройства. Каждая подписка - это уникальный endpoint (URL push-сервиса) плюс пара ключей для шифрования. Важно сразу принять: доставка не гарантирована, push-сервис может задержать или выбросить сообщение, а подписка может протухнуть - к этому мы ещё вернёмся, когда будем писать сервер.
VAPID-ключи - пара ключей на эллиптической кривой P-256 (стандарт RFC 8292), которой ваш сервер представляется push-сервису: «это точно я, а не спамер». Без них уведомления просто не примут.
Notifications API - то, что рисует само системное уведомление поверх ОС (Windows Action Center, macOS Notification Center и т.д.). Требует явного разрешения пользователя, и это разрешение он может забрать в любой момент.
Браузер или установленный PWA?
Это развилка, которую туториалы обычно смазывают, а зря - от неё зависит половина решений. Push живёт в двух режимах, и какой вам доступен, диктует платформа:
Где работает | Нужен ли установленный PWA | |
|---|---|---|
Обычная вкладка браузера | десктопные Chrome, Firefox, Edge и Safari (16.1+ на macOS Ventura); Chrome на Android | нет - Service Worker и подписка работают прямо на открытом сайте |
Только установленный PWA | iOS и iPadOS (Safari 16.4+) | да - в обычной вкладке мобильного Safari push не заведётся вообще |
Практический вывод простой. Всё, что касается Service Worker, Push API и сервера, - общее для обоих режимов и работает в голой вкладке. А PWA-обвязка (манифест, установка на домашний экран) нужна только ради iOS; на десктопе и Android это приятный бонус, но не обязанность. Если iOS в ваших планах нет - раздел про манифест можно пропустить целиком.
Дальше собираем всё руками именно в таком порядке: сначала общая для всех часть (VAPID, Service Worker, клиент, сервер), а PWA и iOS-специфику вынесем отдельными разделами, чтобы не мешать браузерное с «яблочным».
VAPID-ключи: генерируем за одну команду
Начнём с ключей, потому что они понадобятся и клиенту, и серверу. Проще всего взять пакет web-push - он же потом пригодится на бэкенде.
npx web-push generate-vapid-keys
======================================= Public Key: BO0EswuFP5ApodlzrXx85I4b_uh1C1YQggYv7wggqSksMV9qGOL_A1URE0fQ2J3eH4K0xzOGnXwQiUyXMvrjWGE Private Key: xMd5-BGsKWo-H1n__KbNZABGsTF9x9AM8Et1qM84rR8 =======================================
⚠️ Публичный ключ уедет в браузер - это нормально, он для того и публичный. А приватный живёт только на сервере: не коммитьте его, держите в переменных окружения. Ключи выше - просто пример из статьи, сгенерируйте свои.
Service Worker
Вот весь sw.js целиком. Ниже разберём каждую строку - тут есть пара мест, где легко отхватить плавающий баг.
self.addEventListener('install', (event) => { self.skipWaiting(); }); self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener('push', (event) => { // тело может отсутствовать или быть не-JSON, но уведомление показываем всегда: // за «тихие» пуши браузеры наказывают let payload; try { payload = event.data.json(); } catch { payload = { title: 'Новое сообщение', body: event.data?.text() ?? '' }; } const options = { body: payload.body, icon: payload.icon || '/icon-192x192.png', badge: payload.badge || '/badge-72x72.png', tag: payload.tag, data: payload.data, // сюда кладём url для клика requireInteraction: payload.requireInteraction || false, actions: payload.actions || [], }; // waitUntil обязателен: без него SW могут убить до показа уведомления event.waitUntil( self.registration.showNotification(payload.title, options) ); }); self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = event.notification.data?.url || '/'; // если вкладка с сайтом уже открыта - фокусируем её, а не плодим новые event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((tabs) => { const tab = tabs.find((t) => t.url.startsWith(self.location.origin)); if (!tab) return clients.openWindow(url); return tab.focus().then((t) => t.navigate(url)); }) ); });
install и skipWaiting()
По умолчанию новый Service Worker после установки встаёт в очередь и ждёт, пока закроются все вкладки со старой версией - обычный F5 не спасает: в момент перезагрузки старая вкладка ещё жива и держит старый воркер. Для приложения с push это не то поведение, которое нам нужно: обновление логики уведомлений зависнет надолго. self.skipWaiting() говорит «активируйся сразу», минуя ожидание.
Кстати про self: в Service Worker нет window, его роль играет self - глобальный объект самого воркера.
activate и clients.claim()
skipWaiting() активировал воркер, но контроль над уже открытыми вкладками он при этом не получает - они продолжают жить со старым воркером (или вообще без него) до следующей перезагрузки. self.clients.claim() забирает их под управление немедленно.
event.waitUntil(...) гарантирует, что мы дозахватим клиентов до того, как начнём обрабатывать push (что именно делает waitUntil - разберём чуть ниже, там он критичен). Здесь же обычно чистят старые кэши.
push - сердце всего
Событие прилетает, когда push-сервис разбудил браузер новым сообщением. Дальше три момента, на которых спотыкаются чаще всего.
Первый: данные не гарантированы. Тело может не прийти вовсе или оказаться невалидным JSON - поэтому try/catch с фолбэком, а не голый event.data.json(). Обратите внимание: фолбэк всё равно показывает уведомление, а не тихо выходит из обработчика. Подписка с userVisibleOnly: true - это обещание показывать уведомление на каждый push, и за «тихие» пуши браузеры наказывают: Chrome может показать вместо вашего дефолтное «This site has been updated in the background», а Safari после нескольких таких пушей просто отзывает подписку.
Второй: event.waitUntil вокруг showNotification обязателен. Это тот самый неочевидный баг из многих туториалов. showNotification() возвращает промис, а Service Worker движок вправе усыпить сразу после синхронного выхода из обработчика. Без waitUntil уведомление может просто не успеть показаться - особенно на слабом Android. С ним воркер живёт, пока промис не разрешится.
Третий: data в опциях - это ваш «карман» для произвольной нагрузки. Мы кладём туда url, чтобы потом узнать, куда вести по клику.
Остальные опции showNotification() по вкусу: body, icon, image, badge (иконка в шторке Android), tag (группировка/замена уведомлений с одним тегом), requireInteraction (не гаснет само), silent, vibrate, actions (кнопки - поддержка неровная, проверяйте по целевым браузерам).
notificationclick
Клик по уведомлению. Сначала закрываем его руками (event.notification.close() - само оно не всегда исчезает), потом достаём url из того самого data. Открывать сразу новое окно не стоит: если вкладка с сайтом уже есть, вежливее сфокусировать её и перевести на нужный адрес - иначе каждый клик будет плодить по вкладке. waitUntil тут по той же причине, что и выше: не дать воркеру умереть на полпути.
Клиент
Теперь браузерная часть: регистрируем воркер, разбираемся с разрешениями и подписываемся.
const vapidPublicKey = 'BO0EswuFP5ApodlzrXx85I4b_uh1C1YQggYv7wggqSksMV9qGOL_A1URE0fQ2J3eH4K0xzOGnXwQiUyXMvrjWGE'; // Push API хочет ключ в виде Uint8Array, а не base64url-строки function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); return Uint8Array.from(raw, (char) => char.charCodeAt(0)); } async function initServiceWorker() { try { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }); const state = await reg.pushManager.permissionState({ userVisibleOnly: true }); if (state === 'granted') { // разрешение уже есть: живую подписку синхронизируем, // потерянную молча пересоздаём - жест пользователя для этого не нужен const existing = await reg.pushManager.getSubscription(); if (existing) await sendSubscriptionToServer(existing); else await subscribeToPush(); } return state; // 'prompt' | 'granted' | 'denied' } catch (error) { console.error('SW init error:', error); return 'error'; } } async function subscribeToPush() { try { const reg = await navigator.serviceWorker.ready; // ждём активного воркера const subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); await sendSubscriptionToServer(subscription); return true; } catch (error) { console.error('Subscribe error:', error); return false; } } async function sendSubscriptionToServer(subscription) { const json = subscription.toJSON(); if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { throw new Error('В подписке нет обязательных полей'); } await fetch('/web-push', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ endpoint: json.endpoint, keys: { p256dh: json.keys.p256dh, auth: json.keys.auth }, }), }); } // Точка входа if ('serviceWorker' in navigator && 'PushManager' in window) { initServiceWorker().then((state) => { if (state === 'prompt') { // здесь показываем свою кнопку «Включить уведомления», // которая по клику вызовет subscribeToPush() } }); } else { console.warn('Push в этом браузере не поддерживается'); }
Пара вещей, которые сэкономят вам вечер отладки.
subscribe() нужно вызывать по клику пользователя. Формально в некоторых браузерах можно и без жеста, но на практике это работает не везде и легко упирается в блокировки. Не запрашивайте разрешение на первой же секунде загрузки - это верный способ получить denied навсегда. Показывайте свою кнопку и подписывайтесь по ней (в коде это ветка state === 'prompt').
navigator.serviceWorker.ready перед subscribe() - не роскошь. Этот промис резолвится, только когда воркер реально активен, и никогда не реджектится. Попытка подписаться до активации - типичная причина «у меня иногда не подписывается».
Scope можно сузить. register('/sw.js', { scope: '/' }) вешает воркер на весь сайт, но никто не мешает ограничить его папкой:
navigator.serviceWorker.register('/sw.js', { scope: '/admin/' }); navigator.serviceWorker.register('/admin/sw.js'); // scope по умолчанию - '/admin/'
Endpoint зависит от браузера - это нормально, ваш сервер не должен на него завязываться:
Firefox → https://updates.push.services.mozilla.com/wpush/v2/... Chrome → https://fcm.googleapis.com/fcm/send/... Edge → https://wns2-...notify.windows.com/... iOS 16.4+ → https://web.push.apple.com/...
Структура же везде одинаковая - тот самый endpoint плюс ключи keys.p256dh и keys.auth. Именно эту тройку мы и шлём на сервер.
Сервер (минимальный, честно игрушечный)
Дальше - самый маленький бэкенд, который отправляет push. Сразу оговорюсь: это демо, чтобы увидеть работающую цепочку, а не прод. Что нужно докрутить для боя - сразу под кодом.
const express = require('express'); const webpush = require('web-push'); const app = express(); app.use(express.json()); // index.html и sw.js кладём в public/ - SW обязан жить на том же origin app.use(express.static('public')); webpush.setVapidDetails( 'mailto:you@example.com', process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY, ); const subscriptions = new Map(); // endpoint -> subscription (в проде - БД) app.post('/web-push', (req, res) => { const sub = req.body; subscriptions.set(sub.endpoint, sub); res.status(201).json({ ok: true }); }); async function broadcast(payload) { const body = JSON.stringify(payload); for (const sub of subscriptions.values()) { try { await webpush.sendNotification(sub, body); } catch (err) { // 404/410 = подписка мертва, удаляем if (err.statusCode === 404 || err.statusCode === 410) { subscriptions.delete(sub.endpoint); } else { console.error('push failed:', err.statusCode, err.body); } } } } // триггер отправки: подписались в браузере, затем curl -X POST localhost:3000/send app.post('/send', async (req, res) => { await broadcast({ title: 'Привет!', body: 'Тестовое уведомление', data: { url: '/' } }); res.json({ sent: subscriptions.size }); }); app.listen(3000, () => console.log('http://localhost:3000'));
Проверка цепочки: открываете http://localhost:3000, жмёте свою кнопку подписки, затем curl -X POST localhost:3000/send - уведомление придёт, даже если вкладку уже закрыли.
Что здесь уже сделано правильно и почему стоит оставить даже в проде: обработка 404/410. Push-сервис отвечает этими кодами, когда подписка протухла (пользователь снёс приложение, отозвал разрешение, браузер ротировал endpoint). Если их не ловить и не удалять мёртвые подписки, ваш список будет пухнуть, а отправка - тормозить на трупах.
Что докрутить для боя:
Хранилище.
Mapв памяти умирает вместе с процессом - нужна БД.Лимит payload ~4 КБ. Не пихайте в уведомление картинки и простыни текста - шлите id, а данные подтягивайте по клику.
Дедуп и TTL. Один пользователь - несколько устройств/браузеров, у каждого своя подписка; плюс у
sendNotificationесть опцияTTL, сколько push-сервису держать сообщение, если устройство офлайн.Коды ошибок шире. Кроме 404/410 в бою прилетают 403 (типовой случай - публичный ключ в клиенте не от той VAPID-пары, что на сервере) и 429 - сигнал притормозить.
pushsubscriptionchange. Браузер может ротировать подписку; это событие в Service Worker - шанс молча переподписаться. Правда, Chrome его до сих пор не шлёт (Firefox - шлёт), поэтому нужна и подстраховка из клиента выше - синхронизация подписки при каждой загрузке страницы.
PWA-обвязка (нужна ради iOS)
Дальше - «яблочная» территория. Почему манифест для iOS - условие входа, а для десктопа и Android - опция, мы уже разобрали в развилке в начале, так что сразу к делу.
manifest.json:
{ "short_name": "Push App", "name": "Push Notifications App", "description": "Приложение с поддержкой push-уведомлений", "icons": [ { "src": "/icon-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512x512.png", "sizes": "512x512", "type": "image/png" }, { "src": "/icon-maskable-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "start_url": "/", "display": "standalone", "scope": "/", "theme_color": "#27c9b4", "background_color": "#f4f5f7", "orientation": "portrait-primary" }
Из всего этого для push по-настоящему критично одно поле - display: "standalone" (подойдёт и fullscreen): запуск без адресной строки, именно в таких режимах iOS соглашается на push. scope разумно держать совпадающим со scope Service Worker (жёсткого требования нет, просто меньше сюрпризов), start_url - точка входа при запуске с иконки. icons на установку не влияют - iOS добавит приложение на экран и без них, просто иконкой станет сгенерированная монограмма из первой буквы названия (до iOS 16.4 был скриншот страницы). И про maskable: это вариант иконки с полями под обрезку на Android; помечать так единственный набор иконок не стоит - логотип попадёт под нож.
Подключаем в <head>. Строго говоря, apple-метатеги уже не обязательны - современный iOS читает display: standalone прямо из манифеста, - но их принято оставлять как подстраховку для старых версий, а apple-touch-icon - самый надёжный способ задать иконку для домашнего экрана:
<link rel="manifest" href="/manifest.json"> <meta name="theme-color" content="#27c9b4"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="Push App"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
iOS: что изменилось и что требуется
iOS дольше всех сопротивлялся, и историю полезно знать, чтобы понимать текущие ограничения.
iOS 11.3 (2018) - Service Worker появился, но только кэш и офлайн, push нет.
iOS 16.4 (март 2023) - наконец полноценный web-push через APNs, с поддержкой VAPID. Ниже этой версии - никак.
Отдельного списка условий у iOS нет - это всё те же HTTPS, явное разрешение пользователя и установленный PWA, про которые уже говорили. Разница в строгости: здесь каждый пункт не «желательно», а «иначе не заведётся».
Главная практическая засада: пользователь на iPhone открыл ваш сайт в Safari, а вы ему предлагаете «включить уведомления» - и ничего не работает, потому что он не установил PWA. Он про это не знает и уходит. Поэтому стоит распознать ситуацию и подсказать:
function isIOSWithoutPWA() { // iPadOS 13+ прикидывается маком - настоящий Mac отличаем по отсутствию тача const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (/Mac/.test(navigator.userAgent) && navigator.maxTouchPoints > 1); const isStandalone = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true; return isIOS && !isStandalone; } if (isIOSWithoutPWA()) { // показать подсказку: «Поделиться → На экран Домой, потом включите уведомления» }
Итог
Собрали полноценный web-push без единого стороннего сервиса: Service Worker ловит события, Push API держит подписку, VAPID представляет сервер, Notifications API рисует уведомление. Всё это - штатные браузерные API, бесплатно и с полным контролем.
Главные грабли, из-за которых это «не работает» у большинства, я собрал по тексту, но повторю самое дорогое: event.waitUntil вокруг showNotification, разрешение только по клику пользователя, serviceWorker.ready перед subscribe, удаление мёртвых подписок по 404/410 и обязательный установленный PWA на iOS.
Что осталось за кадром и тянет на отдельный разговор: data-only push с догрузкой контента (и как при этом не попасть под санкции за «тихие» пуши), группировка и обновление уведомлений через tag, аналитика доставки. Если интересно - напишите в комментариях, разберу.
И встречный вопрос к тем, кто уже катал web-push в прод: как у вас устроена чистка протухших подписок и какие ошибки, кроме разобранных, приходилось ловить? Любопытно собрать боевые кейсы.
Больше про веб-разработку - в моём телеграм-канале w3d_code.
