Push: подписка, сервер и оговорки по платформам

PWA≈ 21 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Доска открывается без сети, обновляется, не ломая вкладок, и стоит на экране как приложение. Одного она всё ещё не умеет: сказать человеку о новости, когда сама закрыта. Вас назначили на задачу — а узнаете вы об этом, только когда откроете доску. Последняя статья главы — про push. Это разговор трёх сторон: страница подписывается, ваш сервер отправляет сообщение в службу доставки производителя браузера, служба будит воркер, и тот показывает уведомление. Разберём каждую реплику на модели и на живом Chrome с настоящей подпиской, а в конце выясним, где этот разговор обрывается на разных платформах — на iPhone в первую очередь.

Разделы темы:·

Уведомление рисует не страница, а система: поверх других окон, в центре уведомлений, на экране блокировки. Поэтому начинается всё не с кода доски, а с разрешения человека. Спрашивают его через Notification.requestPermission(), а текущее состояние лежит в Notification.permission. Предскажите четыре строки: как и с регистрацией воркера в первой статье главы, песочница учебника отвечает здесь по-настоящему.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Имена на месте — и Notification, и PushManager. А с разрешением отказ, и настоящий: происхождение песочницы непрозрачное, и разрешать уведомления тут некому и не для кого. Третья строка при этом зависит от браузера. Chrome сразу пишет denied, а Firefox в той же песочнице — default, «ещё не спрашивали»; на сам запрос оба отвечают denied. Свойство отвечает на вопрос «что уже решено», запрос — «чем кончилась попытка», и код доски должен уметь все три ответа.

ЗначениеЧто значитЧто делает доска
defaultчеловека ещё не спрашивали или он закрыл окно, не ответивпоказывает кнопку «Сообщать о новых задачах»
grantedразрешилподписывается и показывает, что уведомления включены
deniedзапретилпрячет кнопку и объясняет, где уведомления включить

denied — окончательный ответ для сайта. Повторный requestPermission() окна уже не показывает и сразу возвращает denied; вернуть разрешение может только сам человек, в настройках сайта. Значит, спросить по-настоящему удаётся один раз, и тратить эту попытку на загрузку страницы, когда человек ещё не видел доски, — худшее из решений. У приложений для телефона правило похожее, но не одинаковое: iOS после первого отказа больше не спрашивает, Android 11 и новее — после второго; это разберём в пятой части, в статье «Разрешения: спросить, получить отказ, жить с ним».

Второе правило — спрашивать в ответ на нажатие. Как выглядит нарушение, проверим в Firefox 155 без окна. Страница спрашивает дважды: сразу при загрузке и по нажатию кнопки. Нажимает мой сценарий через WebDriver BiDi — протокол управления браузером; у Firefox это примерно то же, что протокол DevTools у Chrome.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · журнал моего статического сервера на localhost, строки REPORT; Firefox 155 без окна, чистый профиль. Кнопку #notify нажал сценарий через WebDriver BiDi
REPORT {"step":"без нажатия","result":"default","permission":"default"}
REPORT {"step":"по нажатию: ответа нет 10 с","permission":"default"}

Без нажатия Firefox не показал окна и не записал отказа: промис сразу вернул default, запрос просто проигнорирован. По нажатию ответа не было и через десять секунд — окно запроса открылось и ждёт человека, которого в браузере без окна нет. Требовать нажатия Firefox, по данным MDN о поддержке, начал с версии 72. Safari требует нажатия и на Mac, и на iPhone — WebKit пишет об этом прямо. Chrome, по тем же данным MDN, жеста не требует, а мой стенд здесь не судья: Chrome без окна на оба запроса сразу ответил denied — окна запроса у него нет, и отказывает он сам.

Разрешение спрашивают по нажатию на кнопку с понятным смыслом — «Сообщать о новых задачах», — а не при загрузке. Без нажатия запрос в одних браузерах пропадёт, а в других человек нажмёт «Блокировать» раньше, чем поймёт, о чём речь.

Разрешение — только право показывать уведомления. Чтобы сервер мог сообщить что-то закрытой доске, нужна подписка, и здесь в разговор входит третья сторона. Ваш сервер не может держать соединение с каждым телефоном: телефон спит, меняет сети, а сайтов с уведомлениями у него десятки. Поэтому устройство держит одно соединение — со службой доставки производителя браузера, общей для всех сайтов. У Chrome это служба Google, у Firefox — Mozilla, у Safari — Apple. Подписка — адрес в такой службе, по которому ваш сервер оставляет сообщение для этого браузера и этого сайта.

Вот app.js доски. Подписывается она через pushManager регистрации воркера: сообщение приходит в воркер, а не на страницу, и без воркера такой подписки нет.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, свой localhost, чистый профиль. [страница] — консоль доски, строки «===» пишет мой сценарий; проверка userVisibleOnly из этого же прогона убрана — она ниже
[страница] разрешение при загрузке: default
=== нажали «Сообщать о новых задачах» ===
[страница] ответ на запрос: denied
=== сценарий выдал разрешение через Browser.grantPermissions ===
=== нажали «Сообщать о новых задачах» ещё раз ===
[страница] ответ на запрос: granted
[страница] служба доставки: https://fcm.googleapis.com
[страница] ключи: p256dh, auth

Первое нажатие кончилось denied — это Chrome без окна отказал сам, как в опыте выше. Поэтому разрешение стенду выдал мой сценарий через протокол DevTools, командой Browser.grantPermissions; у вас на этом месте было бы окно с кнопками «Разрешить» и «Блокировать». После второго нажатия подписка готова, и её адрес ведёт на fcm.googleapis.com — в службу доставки Google. А на сервер уехало вот что:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · тело POST /api/push-subscriptions из того же прогона, как его сохранил мой сервер; адрес и ключи сокращены мной, в полном адресе 188 символов
{"endpoint":"https://fcm.googleapis.com/fcm/send/e4ltS6J43sI:…","expirationTime":null,"keys":{"p256dh":"BHsKGAv_5M…","auth":"euHUq5…"}}

endpoint — тот самый адрес в службе. keys.p256dh — открытый ключ браузера: им сервер шифрует содержимое сообщения, и прочитать его сможет только этот браузер, служба везёт сообщение запечатанным. keys.auth — общий секрет для того же шифрования. expirationTime: null — срока у подписки нет, но вечной это её не делает; как подписки умирают, увидим в следующем разделе.

Повторное нажатие новой подписки не создаёт: на стенде второй subscribe вернул тот же адрес и те же ключи, а сервер получил два одинаковых POST. Поэтому сервер хранит подписки по endpoint, как по ключу, а не складывает в список.

Теперь applicationServerKey. Это открытая половина пары ключей VAPID, которую сервер доски заводит один раз. Открытая лежит прямо в app.js, прятать её незачем. Закрытая живёт только на сервере: ею он подписывает каждый запрос в службу. Служба запомнила открытый ключ при подписке и проверяет им подпись. Подписать может только владелец закрытого ключа, а проверить — любой, у кого есть открытый. Этот механизм можно пощупать прямо здесь, в примере: криптография браузера, WebCrypto, настоящая. VAPID требует подпись ECDSA на эллиптической кривой P-256 — это имена алгоритма, их браузер и принимает. Uint8Array — массив байтов, а String.fromCharCode и btoa вместе переводят байты в base64 — запись байтов латинскими буквами, цифрами и знаками + и /. Предскажите пять строк, особенно последнюю.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

65 байт с четвёркой в начале — так записывают открытый ключ на кривой P-256: четвёрка означает «дальше обе координаты точки», по 32 байта. В base64url это 87 символов, и первый из них всегда B: у ключа в app.js и у p256dh в подписке — тоже. Главное в двух последних строках: подпись доски проверяется её открытым ключом, а подпись чужим ключом ту же проверку не проходит, хотя данные одинаковые. Настоящий сервер подписывает не произвольную строку, а маленький JSON со сроком годности — токен JWT, где в aud стоит адрес службы, а в sub — контакт владельца сервера.

Вот что ответила служба Google, когда я заменил на сервере пару ключей и отправил сообщение по той же подписке:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал сервера доски, тот же прогон; sendPush — функция отправки, о ней в следующем разделе
чужой ключ → 403 the VAPID credentials in the authorization header do not correspond to the credentials used to create the subscriptions.

403: адрес подписки у отправителя есть, а права писать по нему нет. Утёкшая база подписок сама по себе не даёт рассылать от вашего имени.

Последний параметр — userVisibleOnly: true. Это обещание: каждое сообщение кончится видимым уведомлением. Тихий push, который будит устройство ради фоновой работы, браузеры считают и слежкой, и тратой батареи. Chrome без этого обещания не подписывает:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна, тот же прогон; вторая строка — предупреждение, которое Chrome сам пишет в консоль страницы
NotAllowedError: Registration failed - permission denied
[консоль Chrome] Chrome currently only supports the Push API for subscriptions that will result in user-visible messages. You can indicate this by calling pushManager.subscribe({userVisibleOnly: true}) instead. See https://goo.gl/yqv4Q4 for more details.

Текст ошибки сбивает с толку — «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 в секундах, а не таймеры, так что порядок строк зависит только от логики. Служба, как настоящая, отвечает серверу сразу, а воркер на устройстве просыпается позже. По сценарию телефон уезжает в метро, а на ноутбуке стирают данные сайта, и подписка ноутбука умирает. Предскажите вывод, особенно последние три строки.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Разберём три места. Первое: на «Стендап через 5 минут» служба ответила 201, а уведомления нет нигде. Телефон был без связи, а TTL 0 значит «доставить сейчас или никогда»: служба честно приняла сообщение и честно выбросила. Второе: «Созвон перенесли» с TTL 900 телефона не дождался — тот вернулся через 30 минут, а ждать разрешили 15. И строка службы о просроченном сообщении напечаталась раньше уведомления, хотя отправлено оно было позже: очередь служба разбирает сразу, а воркер просыпается следующим шагом. Третье: ноутбук сервер вычеркнул по первому же 410, и следующие рассылки его уже не спрашивали.

Теперь по-настоящему. К sw.js доски добавляется обработчик push:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

А вот рассылка, которая её зовёт, и первый запуск:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал сервера доски; прогон тот же, что в разделе про подписку
https://fcm.googleapis.com → 201
подписок осталось: 1
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль Chrome 152 без окна: [воркер] — консоль sw.js, последняя строка — ответ проверки из консоли страницы
[воркер] push: Вас назначили на задачу 17
[воркер] показано: Вас назначили на задачу 17
[{"title":"Вас назначили на задачу 17","body":"«Починить вход» — срок пятница","tag":"task","data":{"url":"http://127.0.0.1:3378/task/17"}}]

Сообщение прошло весь путь: сервер получил 201, служба разбудила воркер, воркер показал уведомление, и браузер знает о нём всё — заголовок, текст, тег и адрес. Теперь страница отписывается — unsubscribe() из консоли вернул true, — и сервер рассылает следующее сообщение:

shлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · терминал сервера доски, тот же прогон
https://fcm.googleapis.com → 410 push subscription has unsubscribed or expired.
подписка мертва, удаляю
подписок осталось: 0

410, да ещё с объяснением словами. Отписка из кода страницы — самый простой случай: страница могла бы и сама сказать об этом серверу. Хуже, когда подписка умирает без участия страницы. Например, данные сайта стёрли или браузер их выселил вместе с регистрацией воркера, как в статье «Без сети». Тогда сервер узнает об этом только из ответа службы на следующую рассылку.

Осталось проверить на живой службе то, что в миниатюре было моделью, — TTL. Подписываю доску, закрываю браузер и, пока он закрыт, отправляю четыре сообщения. Два последних — с заголовком Topic: из ожидающих сообщений с одной темой служба хранит только последнее. Разрешение в этом опыте записано в настройки профиля: выданное через протокол DevTools живёт до закрытия браузера, и на первой попытке опыт на этом споткнулся.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · Chrome 152 без окна и терминал сервера доски; строки «===» пишет мой сценарий, [воркер] и [страница] — консоль доски после перезапуска браузера
=== браузер закрыт ===
отправил «TTL 0» → 201
отправил «TTL 3600» → 201
отправил «topic, первое» → 201
отправил «topic, второе» → 201
=== браузер снова запущен (тот же профиль), прошло 21 с ===
[воркер] push: topic, второе
[воркер] показано: topic, второе
[воркер] push: TTL 3600
[воркер] показано: TTL 3600
[страница] разрешение при загрузке: granted
уведомления: ["TTL 3600","topic, второе"]

201 на все четыре, а дошли два. «TTL 0» служба приняла и выбросила: браузер был закрыт, а ждать сообщению не разрешили. Что дело именно в закрытом браузере, показал контрольный прогон — при открытом браузере такое же сообщение дошло:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · отдельный прогон в Chrome 152 без окна; «уведомления» печатает проверка getNotifications() из консоли страницы, тег — в скобках. У воркера в этом прогоне была ещё ветка для тихих push из раздела про подписку, на это сообщение она не срабатывает
=== TTL 0, браузер открыт ===
→ 201
[воркер] push: TTL 0 при открытом браузере
уведомления: ["TTL 0 при открытом браузере [ttl0]"]

Из двух сообщений с темой task-17 дошло второе. А порядок прихода с порядком отправки не совпал: «TTL 3600» ушло первым, а показано вторым. Строить логику на очерёдности push нельзя.

201 от службы — «принято», а не «доставлено». TTL выбирают по смыслу сообщения: «стендап через 5 минут» бессмысленно показывать через час, а «вас назначили на задачу» пригодится и завтра.

Уведомление показано, человек на него нажал. Браузер будит воркер ещё раз — событием notificationclick. Задача обработчика — привести человека туда, о чём уведомление: если карточка задачи уже открыта во вкладке, переключиться на неё, если нет — открыть новую. Вот вторая половина sw.js стенда:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

close() первым делом убирает само уведомление: на него уже отреагировали. clients.matchAll возвращает вкладки сайта, а includeUncontrolled: true добавляет те, которыми воркер пока не управляет, — вспомните первую загрузку из первой статьи главы. focus() переключает на найденную вкладку, openWindow() открывает новую. И снова waitUntil: воркер должен дожить до конца этой работы.

Нажать на уведомление в Chrome без окна нечем, поэтому сам обработчик нажатия стенд не проверил. Зато он проверил, почему весь этот код живёт именно здесь. Я позвал openWindow и focus из консоли воркера — не в ответ на нажатие:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
вывод · консоль воркера в Chrome 152 без окна, тот же прогон, что в разделе про подписку. Первой строкой сценарий проверяет, что спрашивает именно sw.js доски: у Chrome есть и свои воркеры, от расширений
воркер: http://127.0.0.1:3378/sw.js
вкладок: 1 — http://127.0.0.1:3378/
openWindow: InvalidAccessError: Not allowed to open a window.
focus: InvalidAccessError: Not allowed to focus a window.

Вкладка есть, но переключиться на неё воркеру не дали, и новое окно открыть — тоже: InvalidAccessError. Иначе любой сайт с подпиской мог бы по push выдёргивать человека из его дел. Право на окно браузер даёт ненадолго и только в ответ на действие человека; для push-уведомления такое действие — нажатие на него. Соберём модель: createClients на странице примера повторяет это правило. Предскажите вывод трёх нажатий и одной попытки открыть доску прямо из push.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первое нажатие нашло открытую вкладку, второе открыло новую, а третье уже переключилось на неё: вкладка, открытая по второму нажатию, теперь в списке, и дубля не появилось. Последняя строка — то же правило, что на стенде: из push окно не открыть, поэтому доска показывает уведомление и ждёт, пока человек нажмёт сам. Сравнение адресов через === в openBoard пока слишком строгое — это задача в конце статьи.

Открыть или показать вкладку воркер может только в ответ на действие человека, и для push это нажатие на уведомление. Само событие push такого права не даёт.

Сводная таблица возможностей по платформам — в статье про манифест и установку, и столбец про push там уже есть. Здесь — то, что из него следует для кода доски.

ГдеСлужба доставкиКогда можно подписатьсяЗапрос без нажатия
Chrome на компьютере и AndroidGoogle; у Chrome на компьютере адрес на fcm.googleapis.com — снято на стендев любой вкладке после разрешенияпо данным MDN о поддержке, не запрещён
FirefoxMozillaв любой вкладке после разрешенияс версии 72 игнорируется: промис даёт default — снято в Firefox 155
Safari на MacApple, адреса на поддоменах 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, я не проверял, а в источниках, которые я читал, об этом ни слова. Полагаться на это рано.

Соберём решение «что показать» функцией. Проверка «открыто как приложение» из статьи про манифест уже лежит на странице примера — ради этого места она сюда и переехала. Предскажите шесть строк; последняя, как в первом примере статьи, зависит от браузера.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

На 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, содержимое — доски:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

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: true Chrome не подписывает.
  • Ответ 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 вычёркивает всё, что не получилось, и после одной рассылки на сервере осталась одна подписка из трёх живых. Поправьте условие так, чтобы удалялись только мёртвые подписки, а временные отказы их не трогали.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Мертва подписка только при 404 и 410:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строки телефона и планшета становятся телефон → 429 и планшет → 503, а последняя — подписок на сервере: 3. 429 значит «слишком часто»: отправку повторяют позже, выждав. 503 и другие ответы 5xx — беда службы, а не подписки. Условие status >= 400 опасно тем, что его цена копится незаметно: одна авария у службы — и сервер сам вычеркнул всех, кто был на её адресах, а заново люди подписываться не станут, они просто перестанут получать уведомления.

Та же задача, другой адрес

Модель clients из раздела про нажатие уже на странице. Открыты две вкладки доски: карточка 17, на которую перешли из списка, — с ?from=list в адресе, и карточка 42, прокрученная к комментариям, — с #comments. Уведомления приходят с адресами без хвостов, а openBoard сравнивает адреса целиком — и на каждое нажатие открывает новую вкладку, хотя две карточки из трёх уже открыты.

Поправьте поиск вкладки так, чтобы совпадение считалось по пути адреса — /task/17 — без строки запроса и якоря.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Сравнивать нужно pathname обоих адресов:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь первые два нажатия дают фокус на вкладку /task/17?from=list и фокус на вкладку /task/42, а новая вкладка открывается только для задачи 7. Домен сравнивать не нужно: matchAll возвращает только вкладки своего сайта. Путь вычисляется один раз, до поиска, — внутри find он пересчитывался бы для каждой вкладки.

Тег на задачу, а не на доску

Центр уведомлений из модели уже на странице, и ведёт он себя как Chrome 152 на стенде: уведомление с тем же tag заменяет прежнее. Воркер показывает все уведомления с тегом board, поэтому из четырёх сообщений о двух задачах у человека осталось одно — последнее, про задачу 42. О назначении на задачу 17 он так и не узнал.

Сделайте тег на задачу: пусть новости об одной задаче заменяют друг друга, а о разных — лежат рядом. Номер задачи есть в сообщении.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тег собирается из номера задачи:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В центре уведомлений остаются два: task-17: Срок задачи 17 перенесли на пятницу и task-42: Ещё комментарий к задаче 42. Так же вёл себя живой Chrome: три сообщения с тегами task-17, task-17 и task-42 оставили в getNotifications() два уведомления. Без тега не лучше: пять новостей об одной задаче лягут стопкой из пяти. У замены по тегу есть и тонкость: по умолчанию новое уведомление встаёт на место старого молча, без звука и вибрации, а чтобы человек его заметил, в параметры добавляют renotify: true.