Push: подписка, сервер и оговорки по платформам
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска открывается без сети, обновляется, не ломая вкладок, и стоит на экране как приложение. Одного она всё ещё не умеет: сказать человеку о новости, когда сама закрыта. Вас назначили на задачу — а узнаете вы об этом, только когда откроете доску. Последняя статья главы — про push. Это разговор трёх сторон: страница подписывается, ваш сервер отправляет сообщение в службу доставки производителя браузера, служба будит воркер, и тот показывает уведомление. Разберём каждую реплику на модели и на живом Chrome с настоящей подпиской, а в конце выясним, где этот разговор обрывается на разных платформах — на iPhone в первую очередь.
Уведомление рисует не страница, а система: поверх других окон, в центре уведомлений, на экране блокировки. Поэтому начинается всё не с кода доски, а с разрешения человека. Спрашивают его через Notification.requestPermission(), а текущее состояние лежит в Notification.permission. Предскажите четыре строки: как и с регистрацией воркера в первой статье главы, песочница учебника отвечает здесь по-настоящему.
Имена на месте — и Notification, и PushManager. А с разрешением отказ, и настоящий: происхождение песочницы непрозрачное, и разрешать уведомления тут некому и не для кого. Третья строка при этом зависит от браузера. Chrome сразу пишет denied, а Firefox в той же песочнице — default, «ещё не спрашивали»; на сам запрос оба отвечают denied. Свойство отвечает на вопрос «что уже решено», запрос — «чем кончилась попытка», и код доски должен уметь все три ответа.
| Значение | Что значит | Что делает доска |
|---|---|---|
default | человека ещё не спрашивали или он закрыл окно, не ответив | показывает кнопку «Сообщать о новых задачах» |
granted | разрешил | подписывается и показывает, что уведомления включены |
denied | запретил | прячет кнопку и объясняет, где уведомления включить |
denied — окончательный ответ для сайта. Повторный requestPermission() окна уже не показывает и сразу возвращает denied; вернуть разрешение может только сам человек, в настройках сайта. Значит, спросить по-настоящему удаётся один раз, и тратить эту попытку на загрузку страницы, когда человек ещё не видел доски, — худшее из решений. У приложений для телефона правило похожее, но не одинаковое: iOS после первого отказа больше не спрашивает, Android 11 и новее — после второго; это разберём в пятой части, в статье «Разрешения: спросить, получить отказ, жить с ним».
Второе правило — спрашивать в ответ на нажатие. Как выглядит нарушение, проверим в Firefox 155 без окна. Страница спрашивает дважды: сразу при загрузке и по нажатию кнопки. Нажимает мой сценарий через WebDriver BiDi — протокол управления браузером; у Firefox это примерно то же, что протокол DevTools у Chrome.
Без нажатия Firefox не показал окна и не записал отказа: промис сразу вернул default, запрос просто проигнорирован. По нажатию ответа не было и через десять секунд — окно запроса открылось и ждёт человека, которого в браузере без окна нет. Требовать нажатия Firefox, по данным MDN о поддержке, начал с версии 72. Safari требует нажатия и на Mac, и на iPhone — WebKit пишет об этом прямо. Chrome, по тем же данным MDN, жеста не требует, а мой стенд здесь не судья: Chrome без окна на оба запроса сразу ответил denied — окна запроса у него нет, и отказывает он сам.
Разрешение спрашивают по нажатию на кнопку с понятным смыслом — «Сообщать о новых задачах», — а не при загрузке. Без нажатия запрос в одних браузерах пропадёт, а в других человек нажмёт «Блокировать» раньше, чем поймёт, о чём речь.
Разрешение — только право показывать уведомления. Чтобы сервер мог сообщить что-то закрытой доске, нужна подписка, и здесь в разговор входит третья сторона. Ваш сервер не может держать соединение с каждым телефоном: телефон спит, меняет сети, а сайтов с уведомлениями у него десятки. Поэтому устройство держит одно соединение — со службой доставки производителя браузера, общей для всех сайтов. У Chrome это служба Google, у Firefox — Mozilla, у Safari — Apple. Подписка — адрес в такой службе, по которому ваш сервер оставляет сообщение для этого браузера и этого сайта.
Вот app.js доски. Подписывается она через pushManager регистрации воркера: сообщение приходит в воркер, а не на страницу, и без воркера такой подписки нет.
Первое нажатие кончилось denied — это Chrome без окна отказал сам, как в опыте выше. Поэтому разрешение стенду выдал мой сценарий через протокол DevTools, командой Browser.grantPermissions; у вас на этом месте было бы окно с кнопками «Разрешить» и «Блокировать». После второго нажатия подписка готова, и её адрес ведёт на fcm.googleapis.com — в службу доставки Google. А на сервер уехало вот что:
endpoint — тот самый адрес в службе. keys.p256dh — открытый ключ браузера: им сервер шифрует содержимое сообщения, и прочитать его сможет только этот браузер, служба везёт сообщение запечатанным. keys.auth — общий секрет для того же шифрования. expirationTime: null — срока у подписки нет, но вечной это её не делает; как подписки умирают, увидим в следующем разделе.
Повторное нажатие новой подписки не создаёт: на стенде второй subscribe вернул тот же адрес и те же ключи, а сервер получил два одинаковых POST. Поэтому сервер хранит подписки по endpoint, как по ключу, а не складывает в список.
Теперь applicationServerKey. Это открытая половина пары ключей VAPID, которую сервер доски заводит один раз. Открытая лежит прямо в app.js, прятать её незачем. Закрытая живёт только на сервере: ею он подписывает каждый запрос в службу. Служба запомнила открытый ключ при подписке и проверяет им подпись. Подписать может только владелец закрытого ключа, а проверить — любой, у кого есть открытый. Этот механизм можно пощупать прямо здесь, в примере: криптография браузера, WebCrypto, настоящая. VAPID требует подпись ECDSA на эллиптической кривой P-256 — это имена алгоритма, их браузер и принимает. Uint8Array — массив байтов, а String.fromCharCode и btoa вместе переводят байты в base64 — запись байтов латинскими буквами, цифрами и знаками + и /. Предскажите пять строк, особенно последнюю.
65 байт с четвёркой в начале — так записывают открытый ключ на кривой P-256: четвёрка означает «дальше обе координаты точки», по 32 байта. В base64url это 87 символов, и первый из них всегда B: у ключа в app.js и у p256dh в подписке — тоже. Главное в двух последних строках: подпись доски проверяется её открытым ключом, а подпись чужим ключом ту же проверку не проходит, хотя данные одинаковые. Настоящий сервер подписывает не произвольную строку, а маленький JSON со сроком годности — токен JWT, где в aud стоит адрес службы, а в sub — контакт владельца сервера.
Вот что ответила служба Google, когда я заменил на сервере пару ключей и отправил сообщение по той же подписке:
403: адрес подписки у отправителя есть, а права писать по нему нет. Утёкшая база подписок сама по себе не даёт рассылать от вашего имени.
Последний параметр — userVisibleOnly: true. Это обещание: каждое сообщение кончится видимым уведомлением. Тихий push, который будит устройство ради фоновой работы, браузеры считают и слежкой, и тратой батареи. Chrome без этого обещания не подписывает:
Текст ошибки сбивает с толку — «permission denied» при выданном разрешении, — и объясняет причину только предупреждение в консоли. Нарушить обещание можно и позже, просто не показав уведомление. Chrome тогда, по словам web.dev, покажет своё — «This site has been updated in the background», а Safari, по словам WebKit, за такое отзывает подписку. На стенде Chrome без окна ни того, ни другого не показал: в отдельном варианте стенда воркер нарочно ничего не показывал на три push подряд — getNotifications() пуст, а подписка жива. Так что заглушку Chrome своими глазами я не видел.
Открытый ключ VAPID — в коде страницы, закрытый — только на сервере. Потеряли закрытый — подписки придётся собирать заново: служба принимает сообщения только с подписью той пары, с которой подписывались.
Сервер отправляет сообщение POST-запросом на endpoint. Тело зашифровано ключами подписки, запрос подписан ключом VAPID, а в заголовке TTL сказано, сколько секунд сообщению разрешено ждать, если устройство без связи. Сообщение маленькое: службы обязаны принимать хотя бы 4096 байт, поэтому в push кладут заголовок и адрес, а данные доска заберёт сама, когда её откроют. Служба сразу отвечает кодом и дальше разбирается с доставкой без вас.
| Ответ службы | Что значит | Что делает сервер |
|---|---|---|
201 | сообщение принято — именно принято, а не доставлено | ничего |
429 | слишком часто | повторяет позже |
404, 410 | подписки больше нет: истекла или от неё отказались | удаляет подписку |
400, 413 | неверные заголовки; слишком большое сообщение | чинит свой код |
Соберём весь разговор миниатюрой: служба доставки, два устройства и сервер доски. Часы здесь условные — переменная clock в секундах, а не таймеры, так что порядок строк зависит только от логики. Служба, как настоящая, отвечает серверу сразу, а воркер на устройстве просыпается позже. По сценарию телефон уезжает в метро, а на ноутбуке стирают данные сайта, и подписка ноутбука умирает. Предскажите вывод, особенно последние три строки.
Разберём три места. Первое: на «Стендап через 5 минут» служба ответила 201, а уведомления нет нигде. Телефон был без связи, а TTL 0 значит «доставить сейчас или никогда»: служба честно приняла сообщение и честно выбросила. Второе: «Созвон перенесли» с TTL 900 телефона не дождался — тот вернулся через 30 минут, а ждать разрешили 15. И строка службы о просроченном сообщении напечаталась раньше уведомления, хотя отправлено оно было позже: очередь служба разбирает сразу, а воркер просыпается следующим шагом. Третье: ноутбук сервер вычеркнул по первому же 410, и следующие рассылки его уже не спрашивали.
Теперь по-настоящему. К sw.js доски добавляется обработчик push:
showNotification стоит внутри event.waitUntil, и это не украшение. Воркер разбудили ради одного события, и без waitUntil браузер вправе погасить его, не дождавшись показа; web.dev прямо требует показать уведомление до того, как выполнится промис из waitUntil. tag — имя уведомления: новое с тем же тегом заменяет прежнее, а не ложится стопкой, к этому вернёмся в задачах. В data лежит адрес, куда вести человека по нажатию. В sw.js стенда перед этим стояли ещё две строки — skipWaiting() в install и clients.claim() в activate, чтобы опыт не ждал второй загрузки; кеш и маршрутизатор из прошлых статей в нём не участвовали.
Сервер доски — мой сценарий на Node. Функция отправки sendPush в нём моя, строк на сорок: шифрование по RFC 8291 и подпись VAPID по RFC 8292 на встроенном модуле node:crypto. В настоящем проекте её не пишут, а берут из библиотеки, для Node это web-push. Сама отправка в конце — обычный fetch из части I:
А вот рассылка, которая её зовёт, и первый запуск:
Сообщение прошло весь путь: сервер получил 201, служба разбудила воркер, воркер показал уведомление, и браузер знает о нём всё — заголовок, текст, тег и адрес. Теперь страница отписывается — unsubscribe() из консоли вернул true, — и сервер рассылает следующее сообщение:
410, да ещё с объяснением словами. Отписка из кода страницы — самый простой случай: страница могла бы и сама сказать об этом серверу. Хуже, когда подписка умирает без участия страницы. Например, данные сайта стёрли или браузер их выселил вместе с регистрацией воркера, как в статье «Без сети». Тогда сервер узнает об этом только из ответа службы на следующую рассылку.
Осталось проверить на живой службе то, что в миниатюре было моделью, — TTL. Подписываю доску, закрываю браузер и, пока он закрыт, отправляю четыре сообщения. Два последних — с заголовком Topic: из ожидающих сообщений с одной темой служба хранит только последнее. Разрешение в этом опыте записано в настройки профиля: выданное через протокол DevTools живёт до закрытия браузера, и на первой попытке опыт на этом споткнулся.
201 на все четыре, а дошли два. «TTL 0» служба приняла и выбросила: браузер был закрыт, а ждать сообщению не разрешили. Что дело именно в закрытом браузере, показал контрольный прогон — при открытом браузере такое же сообщение дошло:
Из двух сообщений с темой task-17 дошло второе. А порядок прихода с порядком отправки не совпал: «TTL 3600» ушло первым, а показано вторым. Строить логику на очерёдности push нельзя.
201 от службы — «принято», а не «доставлено». TTL выбирают по смыслу сообщения: «стендап через 5 минут» бессмысленно показывать через час, а «вас назначили на задачу» пригодится и завтра.
Уведомление показано, человек на него нажал. Браузер будит воркер ещё раз — событием notificationclick. Задача обработчика — привести человека туда, о чём уведомление: если карточка задачи уже открыта во вкладке, переключиться на неё, если нет — открыть новую. Вот вторая половина sw.js стенда:
close() первым делом убирает само уведомление: на него уже отреагировали. clients.matchAll возвращает вкладки сайта, а includeUncontrolled: true добавляет те, которыми воркер пока не управляет, — вспомните первую загрузку из первой статьи главы. focus() переключает на найденную вкладку, openWindow() открывает новую. И снова waitUntil: воркер должен дожить до конца этой работы.
Нажать на уведомление в Chrome без окна нечем, поэтому сам обработчик нажатия стенд не проверил. Зато он проверил, почему весь этот код живёт именно здесь. Я позвал openWindow и focus из консоли воркера — не в ответ на нажатие:
Вкладка есть, но переключиться на неё воркеру не дали, и новое окно открыть — тоже: InvalidAccessError. Иначе любой сайт с подпиской мог бы по push выдёргивать человека из его дел. Право на окно браузер даёт ненадолго и только в ответ на действие человека; для push-уведомления такое действие — нажатие на него. Соберём модель: createClients на странице примера повторяет это правило. Предскажите вывод трёх нажатий и одной попытки открыть доску прямо из push.
Первое нажатие нашло открытую вкладку, второе открыло новую, а третье уже переключилось на неё: вкладка, открытая по второму нажатию, теперь в списке, и дубля не появилось. Последняя строка — то же правило, что на стенде: из push окно не открыть, поэтому доска показывает уведомление и ждёт, пока человек нажмёт сам. Сравнение адресов через === в openBoard пока слишком строгое — это задача в конце статьи.
Открыть или показать вкладку воркер может только в ответ на действие человека, и для push это нажатие на уведомление. Само событие push такого права не даёт.
Сводная таблица возможностей по платформам — в статье про манифест и установку, и столбец про push там уже есть. Здесь — то, что из него следует для кода доски.
| Где | Служба доставки | Когда можно подписаться | Запрос без нажатия |
|---|---|---|---|
| Chrome на компьютере и Android | Google; у Chrome на компьютере адрес на fcm.googleapis.com — снято на стенде | в любой вкладке после разрешения | по данным MDN о поддержке, не запрещён |
| Firefox | Mozilla | в любой вкладке после разрешения | с версии 72 игнорируется: промис даёт default — снято в Firefox 155 |
| Safari на Mac | Apple, адреса на поддоменах push.apple.com | во вкладке — с Safari 16 в macOS Ventura, у приложения в Dock — с Safari 17 в macOS Sonoma | нельзя: подписка требует нажатия |
| Safari на iPhone и iPad | та же служба Apple | с iOS и iPadOS 16.4, только у приложения на экране Домой; из вкладки Safari — нет | нельзя: только в ответ на нажатие |
Главная оговорка — iPhone и iPad. Push там есть с iOS и iPadOS 16.4, и только у веб-приложения, поставленного на экран Домой, с манифестом, где display равен standalone или fullscreen. Из вкладки Safari подписаться нельзя вовсе. Разрешения такого приложения человек потом настраивает в «Настройках» → «Уведомления», как у любого другого, с учётом режимов фокусирования. Для доски это значит, что во вкладке на iPhone кнопка «Сообщать о новых задачах» бессмысленна, честнее подсказка «сначала поставьте доску на экран». С iOS 26 на экран Домой как веб-приложение встаёт любой сайт, даже без манифеста; появляется ли у такого сайта push, я не проверял, а в источниках, которые я читал, об этом ни слова. Полагаться на это рано.
Соберём решение «что показать» функцией. Проверка «открыто как приложение» из статьи про манифест уже лежит на странице примера — ради этого места она сюда и переехала. Предскажите шесть строк; последняя, как в первом примере статьи, зависит от браузера.
На iPhone одна и та же доска даёт разные ответы во вкладке и с экрана Домой — различает их launchedAsApp, а не версия системы. Разрешение проверяется первым: при denied никакая кнопка не поможет, помочь может только подсказка, где его вернуть. Последняя строка в Chrome — подсказка про запрет, а в Firefox — кнопка, ведь там permission равен default. И это полезное напоминание: default обещает возможность спросить, а не согласие — запрос в той же песочнице, как мы видели в начале статьи, вернёт denied.
Вторая оговорка про Apple — Declarative Web Push. В iOS и iPadOS 18.4 у приложений на экране Домой и в Safari 18.5 на macOS уведомление можно показать вообще без service worker. Подписка делается через window.pushManager, прямо со страницы, а сервер присылает сообщение в стандартном виде, который система понимает сама. Формат — из статьи WebKit, содержимое — доски:
8030 — номер RFC, который описывает Web Push; здесь он служит меткой формата. navigate — куда вести по нажатию, и notificationclick для этого не нужен. Если воркер у сайта всё же есть, он получает push как обычно и может показать своё уведомление вместо присланного; не показал — система покажет присланное. Зачем это понадобилось, WebKit объясняет прямо: защита от слежки стирает данные давно не посещённых сайтов, в том числе регистрацию воркера, — то самое выселение из статьи «Без сети», — и подписка, привязанная к воркеру, после этого бесполезна. В песочнице учебника "pushManager" in window и в Chrome, и в Firefox даёт false: этого пути у них нет.
Push на iPhone — это сначала «поставьте на экран Домой», и только потом кнопка. Код, который проверяет одно лишь наличие PushManager, на iPhone ошибётся.
Доска прошла всю главу. Она открывается без сети и показывает запасную страницу вместо ошибки браузера, обновляется, не ломая открытых вкладок, ставится на экран и теперь сообщает о новых задачах, даже когда закрыта. На этом кончается рассказ о том, что браузер даёт JavaScript сам: страница, события, формы, хранилище и воркер. Последняя глава части — про чужой код, который берут из npm, когда платформы мало: полка браузера. После неё в части III тот же JavaScript получит типы, и первым делом компилятор пройдётся по коду из части I.
- Push — разговор трёх сторон: страница по нажатию получает разрешение и подписку, ваш сервер шлёт зашифрованное сообщение на адрес подписки в службе доставки производителя браузера, служба будит воркер событием
push, воркер показывает уведомление, аnotificationclickвозвращает человека в доску. - Разрешение бывает
default,grantedиdenied, иdeniedокончателен: вернуть разрешение может только человек. Спрашивают по нажатию: Firefox запрос без нажатия игнорирует, а Safari без нажатия не подписывает. pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })даётendpointи ключиp256dhиauth; хранят подписку поendpoint. Открытый ключ VAPID лежит в коде страницы, закрытым сервер подписывает запросы, и чужую подпись служба отклоняет с403. БезuserVisibleOnly: trueChrome не подписывает.- Ответ
201— «принято», а не «доставлено»: без связи сообщение ждёт не дольшеTTL, а сTTL 0пропадает.404и410— подписка мертва, её удаляют;429и5xx— повторяют позже. Уведомление показывают внутриevent.waitUntil. clients.openWindowиfocusработают только в ответ на действие человека — для push этоnotificationclick; вне его они даютInvalidAccessError. Вкладку ищут черезclients.matchAllи сравнивают по пути адреса.- На iPhone и iPad push есть с 16.4 и только у приложения на экране Домой с манифестом
standaloneилиfullscreen. Declarative Web Push (iOS и iPadOS 18.4, Safari 18.5 на macOS) показывает уведомление без service worker — по JSON-сообщению с"web_push": 8030.
1/4Notification.permission равен "denied". Человек нажимает «Сообщать о новых задачах», и доска зовёт Notification.requestPermission(). Что будет?
2/4Сервер отправил сообщение с TTL: 0, служба ответила 201. Телефон в этот момент был без связи и через минуту вернулся в сеть. Что увидит человек?
3/4Служба доставки ответила серверу 410. Что с этим делать?
4/4Доска открыта во вкладке Safari на iPhone с iOS 18. Человек нажимает «Сообщать о новых задачах». Что получится?
Кого вычёркивать
Модель службы и устройства из раздела про доставку уже на странице. Сервер разослал сообщение четырём подпискам, и ответы пришли разные: ноутбук отписался, на адрес телефона сервер шлёт слишком часто, по адресу планшета у службы авария, а компьютер в порядке.
Сейчас notify вычёркивает всё, что не получилось, и после одной рассылки на сервере осталась одна подписка из трёх живых. Поправьте условие так, чтобы удалялись только мёртвые подписки, а временные отказы их не трогали.
Мертва подписка только при 404 и 410:
Строки телефона и планшета становятся телефон → 429 и планшет → 503, а последняя — подписок на сервере: 3. 429 значит «слишком часто»: отправку повторяют позже, выждав. 503 и другие ответы 5xx — беда службы, а не подписки. Условие status >= 400 опасно тем, что его цена копится незаметно: одна авария у службы — и сервер сам вычеркнул всех, кто был на её адресах, а заново люди подписываться не станут, они просто перестанут получать уведомления.
Та же задача, другой адрес
Модель clients из раздела про нажатие уже на странице. Открыты две вкладки доски: карточка 17, на которую перешли из списка, — с ?from=list в адресе, и карточка 42, прокрученная к комментариям, — с #comments. Уведомления приходят с адресами без хвостов, а openBoard сравнивает адреса целиком — и на каждое нажатие открывает новую вкладку, хотя две карточки из трёх уже открыты.
Поправьте поиск вкладки так, чтобы совпадение считалось по пути адреса — /task/17 — без строки запроса и якоря.
Сравнивать нужно pathname обоих адресов:
Теперь первые два нажатия дают фокус на вкладку /task/17?from=list и фокус на вкладку /task/42, а новая вкладка открывается только для задачи 7. Домен сравнивать не нужно: matchAll возвращает только вкладки своего сайта. Путь вычисляется один раз, до поиска, — внутри find он пересчитывался бы для каждой вкладки.
Тег на задачу, а не на доску
Центр уведомлений из модели уже на странице, и ведёт он себя как Chrome 152 на стенде: уведомление с тем же tag заменяет прежнее. Воркер показывает все уведомления с тегом board, поэтому из четырёх сообщений о двух задачах у человека осталось одно — последнее, про задачу 42. О назначении на задачу 17 он так и не узнал.
Сделайте тег на задачу: пусть новости об одной задаче заменяют друг друга, а о разных — лежат рядом. Номер задачи есть в сообщении.
Тег собирается из номера задачи:
В центре уведомлений остаются два: task-17: Срок задачи 17 перенесли на пятницу и task-42: Ещё комментарий к задаче 42. Так же вёл себя живой Chrome: три сообщения с тегами task-17, task-17 и task-42 оставили в getNotifications() два уведомления. Без тега не лучше: пять новостей об одной задаче лягут стопкой из пяти. У замены по тегу есть и тонкость: по умолчанию новое уведомление встаёт на место старого молча, без звука и вибрации, а чтобы человек его заметил, в параметры добавляют renotify: true.