Web Push сповіщення
Web Push дозволяє серверу показати користувачу системне сповіщення, навіть коли вкладку із застосунком закрито: повідомлення доставляє push-сервіс браузера (Google FCM для Chrome, Mozilla autopush для Firefox, Apple для Safari), а показує його Service Worker. У шаблоні вже є все потрібне: підписка у фронтенді, збереження підписок у PostgreSQL, відправка через бібліотеку web-push, журнал відправок і статистика. Щоб це запрацювало, потрібні лише VAPID-ключі.
Як це працює
- Користувач вмикає сповіщення в акаунті (
UserAccount.vue). Браузер питає дозвіл (Notification.requestPermission()). - Фронтенд бере публічний VAPID-ключ з бекенду і просить браузер створити підписку:
pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }). - Браузер реєструється в своєму push-сервісі й повертає підписку: унікальний
endpoint(URL у push-сервісі) і ключі шифруванняp256dhтаauth. - Фронтенд відправляє підписку на бекенд, бекенд зберігає її в таблиці
push_subscriptions. - Коли трапляється подія (новий вхід, зміна пароля, відповідь підтримки), бекенд шифрує payload ключами підписки, підписує запит приватним VAPID-ключем і робить
POSTнаendpoint. - Push-сервіс перевіряє підпис, будить Service Worker браузера, а той показує сповіщення. Клік відкриває
data.url.
Що таке VAPID-ключі
VAPID (Voluntary Application Server Identification, RFC 8292) — це спосіб, яким ваш сервер доводить push-сервісу браузера, що він має право надсилати повідомлення на конкретну підписку.
- Це пара ключів ECDSA на кривій P-256: публічний і приватний, у форматі base64url.
- Публічний ключ не секретний. Фронтенд передає його браузеру як
applicationServerKeyпід час підписки, і push-сервіс «прив'язує» підписку до нього. - Приватний ключ — секрет, він лише на сервері. Ним бекенд підписує короткий JWT у заголовку
Authorizationкожного push-запиту. Push-сервіс перевіряє підпис публічним ключем з підписки. Тож навіть якщо хтось дізнаєтьсяendpoint, без приватного ключа він не надішле повідомлення. VAPID_SUBJECT— контакт власника сервера (mailto:абоhttps:URL). Його отримує push-сервіс, щоб зв'язатися з вами, якщо з вашими запитами щось не так.
VAPID не шифрує повідомлення
VAPID лише ідентифікує сервер. Вміст повідомлення шифрується окремо ключами самої підписки (p256dh, auth, RFC 8291), тож push-сервіс не бачить, що ви надсилаєте. Бібліотека web-push робить і те, і інше.
Ключі не можна просто змінити
Кожна підписка прив'язана до публічного ключа, з яким її створили. Якщо замінити ключі, усі наявні підписки перестануть працювати: push-сервіс відхилятиме запити, і користувачам доведеться підписатися знову. Генеруйте ключі один раз на оточення і зберігайте приватний ключ як секрет (менеджер секретів, змінні CI), а не в репозиторії.
Генерація ключів
У бекенді є скрипт generate-vapid-keys.ts:
# надрукувати нову пару ключів
pnpm --filter backend push:vapid
# записати ключі прямо в packages/backend/.env
pnpm --filter backend push:vapid --write- З кореня репозиторію те саме запускає
pnpm push:vapid(іpnpm push:vapid --write). - Без прапорців скрипт лише друкує рядки
VAPID_PUBLIC_KEY=...іVAPID_PRIVATE_KEY=..., які треба додати в.env. --writeоновлюєVAPID_PUBLIC_KEYіVAPID_PRIVATE_KEYуpackages/backend/.env, решту файлу не чіпає. Якщо файлу немає, створює його.- Якщо приватний ключ у
.envуже задано,--writeвідмовляється (код виходу1), щоб випадково не зламати наявні підписки. Щоб таки замінити ключі, додайте--force.
Після генерації задайте VAPID_SUBJECT (наприклад mailto:admin@your-domain.com) і перезапустіть бекенд.
Конфігурація
config/push.ts читає три змінні:
| Змінна | За замовчуванням | Призначення |
|---|---|---|
VAPID_PUBLIC_KEY | '' | публічний ключ, віддається фронтенду |
VAPID_PRIVATE_KEY | '' | приватний ключ, секрет |
VAPID_SUBJECT | mailto:admin@vibe-template.example | контакт для push-сервісу |
Під час першої відправки push-notification-sender-service.ts викликає webpush.setVapidDetails(...). Якщо ключі порожні, пише VAPID keys not configured — push notifications disabled і нічого не надсилає ({ sent: 0, failed: 0 }). GET /push-subscriptions/vapid-public-key і POST /push-subscriptions/test-push у цьому випадку відповідають 500 VAPID keys not configured, тож підписатися неможливо. Решта застосунку працює.
Типи сповіщень
Кожне сповіщення має тип notificationType, а підписка — необов'язковий список notificationTypes ('mention' | 'system'), які вона хоче отримувати. Порожній список означає «усі типи».
| Тип | Що це |
|---|---|
system | сповіщення безпеки акаунта |
mention | повідомлення, адресовані користувачу (відповідь підтримки) |
HTTP-маршрути
Група push-subscriptions у http-routes.ts з middleware session_web і csrf_guard; на кожному маршруті, крім vapid-public-key, стоїть auth_guard (анонімний запит → 401 до контролера); контролер — push-subscription-controller.ts. Фронтенд звертається до них з префіксом /api (utils/api.ts).
| Метод | Шлях | Призначення |
|---|---|---|
GET | /push-subscriptions/vapid-public-key | Публічний VAPID-ключ |
POST | /push-subscriptions/test-push | Тестовий push поточному користувачу; тіло { title?, body? } (PushSubscriptionTestInputSchema: до 200 / 1000 символів), відповідь містить sent/failed |
GET | /push-subscriptions | Підписки користувача |
POST | /push-subscriptions | Створити підписку (CreateSubscriptionInputSchema) |
GET | /push-subscriptions/:subscriptionId | Одна підписка |
PUT | /push-subscriptions/:subscriptionId | Оновити (UpdateSubscriptionInputSchema) |
DELETE | /push-subscriptions/:subscriptionId | Видалити |
GET | /push-subscriptions/:subscriptionId/logs | Журнал відправок |
GET | /push-subscriptions/:subscriptionId/statistics | Статистика |
PUT | /push-subscriptions/:subscriptionId/deactivate | Деактивувати |
Маршрути з :subscriptionId працюють лише зі своїми підписками. GET /:subscriptionId, PUT, DELETE, …/statistics і …/deactivate для чужого й неіснуючого id дають однакову відповідь 404 Subscription not found: чужий запис не змінюється і не потрапляє у відповідь. Для PUT і deactivate це забезпечує сам UPDATE … WHERE id AND user_id … RETURNING у репозиторії, без повторного читання за одним id. GET /:subscriptionId/logs підписку не шукає: журнал фільтрується за власником, тож для чужого й неіснуючого id це 200 з порожнім списком data (чужі записи не повертаються). Перенесення підписки на іншого користувача можливе лише через POST /push-subscriptions з тими самими ключами (див. нижче).
Збереження підписки
push-subscription-service.ts у createSubscription шукає підписку за endpoint (він унікальний серед усіх користувачів). Якщо підписка належить іншому користувачу, її переносять на поточного лише тоді, коли p256dhKey і authKey збігаються з збереженими (той самий браузер, зміна акаунта); інакше — 409 conflict, підписка лишається за старим власником. Якщо вона є — оновлює ключі p256dhKey/authKey, метадані пристрою, notificationTypes, timezone і знову робить isActive: true; інакше створює новий запис. Робота з таблицею — через репозиторій, див. Репозиторії.
Відправка та формат payload
pushNotificationSenderService.sendToUser(userId, payload, { skipIfOnline?, notificationType? }):
- Якщо
skipIfOnlineі користувач має активне WebSocket-з'єднання (wsConnectionRegistry.isUserOnline) — push пропускається (див. WebSocket). - Береться список активних підписок; якщо в підписки непорожній
notificationTypesі він не міститьnotificationType— її пропускають. - Для кожної підписки викликається
webpush.sendNotificationз JSON:
{
"notification": {
"title": "...",
"body": "...",
"icon": "/icons/icon_192x192.png",
"badge": "/icons/badge-72.png",
"vibrate": [200, 100, 200],
"data": { "url": "/", "...": "payload.data" },
"tag": "...",
"actions": [
{ "action": "open", "title": "Open" },
{ "action": "close", "title": "Close" }
]
}
}Активні підписки (з урахуванням notificationType) обробляються паралельно, не більше PUSH_SEND_CONCURRENCY = 5 запитів до push-сервісу одночасно. Результат кожної спроби — рядок pushNotificationLogs зі статусом SENT (і statusCode відповіді) або FAILED (з errorMessage); після всіх відправок рядки пишуться одним insertMany через push-notification-log-repository.ts, а lastUsedAt успішних підписок оновлюється одним touchLastUsed. Якщо запис не вдався, помилка логується, а результат { sent, failed } відповідає фактичним відповідям push-сервісу.
Прострочені підписки: відповідь push-сервісу 410 або 404 деактивує підписку (pushSubscriptionRepository.deactivate). Окремої фонової очистки чи видалення неактивних записів немає.
Бізнес-події (user-notification-service.ts). Push надсилається без очікування: збій або повільний push-сервіс не змінює й не затримує відповідь, помилка — лише warn.
| Подія | Де | notificationType | skipIfOnline |
|---|---|---|---|
Новий вхід (new_sign_in) | успішний login (email/телефон) і OAuth-callback | system | ні |
Зміна пароля (password_changed) | POST /auth/change-password | system | ні |
Скидання пароля (password_reset) | скидання за email-токеном і за SMS | system | ні |
Вихід з усіх пристроїв (logout_all) | POST /auth/logout-all | system | ні |
| Відповідь підтримки | збережена відповідь Support AI (support/support_chat) | mention | так |
Сповіщення безпеки йдуть на всі активні підписки, зокрема й на пристрій, з якого виконано дію: підписки не прив'язані до сесії. Відповідь підтримки доходить лише тоді, коли в користувача немає відкритого WS-з'єднання; оскільки закриття з'єднання зупиняє генерацію, на практиці це випадок, коли вкладка закрилася вже після збереження відповіді. Підписка, у якої notificationTypes не містить відповідного типу, push не отримує. Окремо є ендпоінт test-push.
Межі skipIfOnline
«Онлайн» означає лише одне: у цьому процесі є хоча б одне відкрите WS-з'єднання користувача (ws-connection-registry.ts, Map у пам'яті, isUserOnline = userStorage.has(userId)). Звідси:
- Кілька інстансів. З'єднання на іншому інстансі не видно: користувач, онлайн там, вважається офлайн, і push прийде. Спільного реєстру (Redis тощо) немає.
- Фонова вкладка — теж онлайн. Відкрите з'єднання у згорнутій вкладці чи заблокованому телефоні пригнічує push, хоча користувач повідомлення не бачить.
- Момент перевірки. Стан читається один раз перед надсиланням; з'єднання, яке закрилося одразу після перевірки, push уже не поверне.
- Обрив без close. Поки сервер не помітив розрив (heartbeat), з'єднання рахується відкритим.
Статистика
push-subscription-statistics-service.ts рахує totalNotifications, sentNotifications, failedNotifications, pendingNotifications, successRate (%), last7DaysCount, lastUsed, isActive. Лічильники дає один SQL-запит countsBySubscription (count(case …)), межу 7 днів обчислює застосунок і передає параметром.
Фронтенд
- Підписка — у
stores/push.ts(опис пристрою для сервера — уutils/push-device.ts): перевіряєregistration.pushManager.getSubscription(), отримує VAPID-ключ з/api/push-subscriptions/vapid-public-key, викликаєpushManager.subscribe({ applicationServerKey })і відправляє дані наPOST /api/push-subscriptions. service-worker.tsу обробникуpushпарсить JSON, зливаєpayload.notificationз дефолтами і викликаєshowNotification;notificationclickзакриває нотифікацію і відкриваєdata.url. Адреса приходить із payload, томуnotification-target.tsприймає лише адресу того ж origin (інакше —/): чужий хост, порт чи схема,//host,/\host,javascript:відкидаються. Уже відкрите вікно знаходиться за збігом origin іpathname, а не за підрядком в URL, тож/support/archiveне береться за/support.
Локальне тестування
- Згенеруйте ключі:
pnpm --filter backend push:vapid --write, і перезапустіть бекенд. - Service Worker реєструється лише в production-збірці (див. Фронтенд → PWA), а push потребує
localhostабо HTTPS. Зберіть фронтенд і віддайте його бекендом або черезvite preview. - Увійдіть, увімкніть сповіщення в акаунті й дозвольте їх у браузері.
- Викличте
POST /api/push-subscriptions/test-push(тіло{}або{ "title": "…", "body": "…" }, з CSRF-токеном) з активною сесією. Результат видно у відповіді (sent/failed), у логах бекенду та через/:subscriptionId/logs.
Юніт-тести: pnpm --filter backend test, зокрема push-subscription-service.spec.ts.