/
threed
/
claud-web
Обзор
Документация
Войти
/
threed
/
claud-web
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
lib/push.js
147 строк
5 KB
Alexander Zelenski
Уведомления о завершении работы Claude, стабильный вход и PWA
04 авг 2026, 14:26
04 авг 2026, 14:26
b6ac18e
Код
Авторство
О чём код?
/** * Web Push: уведомления о том, что Claude закончил работу во вкладке. * * Почему не хватает Notification в самой странице: в свёрнутом iOS-PWA JS * страницы не выполняется. Достучаться может только настоящий Web Push * через сервис-воркер (iOS 16.4+, приложение добавлено на домашний экран, * сайт по HTTPS). * * Ключи VAPID генерируются один раз в data/vapid.json: при их смене все * существующие подписки становятся недействительными. Подписки браузеров — * в data/push-subscriptions.json. */ const fs = require('fs'); const path = require('path'); const webpush = require('web-push'); const DATA_DIR = path.join(__dirname, '..', 'data'); const VAPID_FILE = path.join(DATA_DIR, 'vapid.json'); const SUBS_FILE = path.join(DATA_DIR, 'push-subscriptions.json'); // mailto: обязателен по спеке VAPID; конкретный адрес значения не имеет const CONTACT = process.env.PUSH_CONTACT || 'mailto:admin@localhost'; let vapid = null; let subscriptions = []; function loadVapid() { try { const saved = JSON.parse(fs.readFileSync(VAPID_FILE, 'utf8')); if (saved.publicKey && saved.privateKey) return saved; } catch { /* нет файла или битый JSON — сгенерируем */ } const generated = webpush.generateVAPIDKeys(); try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(VAPID_FILE, JSON.stringify(generated, null, 2), { mode: 0o600 }); } catch (err) { console.warn('⚠️ Не удалось сохранить vapid.json:', err.message); } return generated; } function loadSubscriptions() { try { const saved = JSON.parse(fs.readFileSync(SUBS_FILE, 'utf8')); return Array.isArray(saved) ? saved : []; } catch { return []; } } function persistSubscriptions() { try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(SUBS_FILE, JSON.stringify(subscriptions, null, 2), { mode: 0o600 }); } catch (err) { console.warn('⚠️ Не удалось сохранить push-subscriptions.json:', err.message); } } function init() { vapid = loadVapid(); subscriptions = loadSubscriptions(); webpush.setVapidDetails(CONTACT, vapid.publicKey, vapid.privateKey); } function publicKey() { return vapid?.publicKey || ''; } /** Валидна ли подписка из браузера (минимальная проверка формы). */ function isValid(sub) { return Boolean( sub && typeof sub.endpoint === 'string' && /^https:\/\//.test(sub.endpoint) && sub.keys && typeof sub.keys.p256dh === 'string' && typeof sub.keys.auth === 'string' ); } /** Добавляет/обновляет подписку (ключ — endpoint). */ function subscribe(sub) { if (!isValid(sub)) throw new Error('Некорректная подписка на уведомления'); const record = { endpoint: sub.endpoint, keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth }, createdAt: Date.now() }; const idx = subscriptions.findIndex(s => s.endpoint === record.endpoint); if (idx >= 0) { subscriptions[idx] = { ...subscriptions[idx], ...record }; } else { subscriptions.push(record); } persistSubscriptions(); return subscriptions.length; } function unsubscribe(endpoint) { const before = subscriptions.length; subscriptions = subscriptions.filter(s => s.endpoint !== endpoint); if (subscriptions.length !== before) persistSubscriptions(); return before - subscriptions.length; } function count() { return subscriptions.length; } /** * Шлёт уведомление всем подписчикам. * Протухшие подписки (404/410 от push-сервиса) удаляем — иначе список растёт * вечно, а каждая отправка тратит время на заведомо мёртвые адреса. */ async function broadcast(payload) { if (!subscriptions.length) return { sent: 0, removed: 0 }; const body = JSON.stringify(payload); const dead = []; const results = await Promise.allSettled( subscriptions.map(sub => webpush.sendNotification(sub, body, { TTL: 300 }).catch(err => { if (err.statusCode === 404 || err.statusCode === 410) { dead.push(sub.endpoint); } else { console.warn('push:', err.statusCode || '', err.message); } throw err; }) ) ); if (dead.length) { subscriptions = subscriptions.filter(s => !dead.includes(s.endpoint)); persistSubscriptions(); } return { sent: results.filter(r => r.status === 'fulfilled').length, removed: dead.length }; } init(); module.exports = { publicKey, subscribe, unsubscribe, broadcast, count };