const { createLogger } = require('./logger')
const log = createLogger('push')
/**
* Web Push Module — real server-sent push notifications
* Each signal triggers immediate push to all subscribers
* Works even when browser is closed (via browser push service)
*/
const webpush = require('web-push')
let _stmts = null
let _enabled = false
function init({ stmts }) {
_stmts = stmts
const vapidPublic = process.env.VAPID_PUBLIC_KEY
const vapidPrivate = process.env.VAPID_PRIVATE_KEY
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@szhub.space'
if (!vapidPublic || !vapidPrivate) {
log.warn('VAPID keys not configured — push disabled')
return
}
webpush.setVapidDetails(vapidSubject, vapidPublic, vapidPrivate)
_enabled = true
const count = _stmts.countPushSubs.get()?.count || 0
log.info({ subscriptions: count }, 'Web Push initialized')
}
/**
* Send notification with retry for transient network errors.
* Fire-and-forget: runs in background, never blocks caller.
* Retries: 2 attempts with 1s/3s backoff. Only retries network errors (no statusCode).
* 410/404 = expired subscription → delete. Other 4xx = permanent, don't retry.
*/
async function sendWithRetry(pushSub, payload, endpoint, maxRetries = 2) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await webpush.sendNotification(pushSub, payload, { TTL: 300, timeout: 10000 })
try { _stmts.resetPushFail.run(endpoint) } catch {}
return // success
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
try { _stmts.deletePushSub.run(endpoint) } catch {}
log.info('Removed expired subscription')
return
}
// Network error (no statusCode) — retry
const isTransient = !err.statusCode
if (isTransient && attempt < maxRetries) {
const delay = (attempt + 1) * 2000 // 2s, 4s
await new Promise(r => setTimeout(r, delay))
continue
}
// Give up
try { _stmts.incrementPushFail.run(endpoint) } catch {}
if (attempt === maxRetries && isTransient) {
log.warn({ attempts: maxRetries + 1, err: err.message }, 'Push failed after retries')
} else {
log.error({ statusCode: err.statusCode, err: err.message }, 'Push send error')
}
return
}
}
}
/**
* Send push notification for a signal — called immediately from emitSignal()
* Fire-and-forget: never blocks signal emission
*/
function sendPushForSignal(signal) {
if (!_enabled || !_stmts) return
// Never push test signals
if (String(signal.id).startsWith('test-')) return
const subs = _stmts.getAllPushSubs.all()
if (!subs.length) return
const ticker = signal.symbol.replace('USDT', '')
const dir = signal.direction === 'LONG' ? '▲ LONG' : '▼ SHORT'
const iconMap = { volume_spike: '📊', liq_sweep: '🎯', oi_cvd: '🔮', oi_divergence: '🔀', oi_funding_squeeze: '⚡', channel: '📐' }
const icon = iconMap[signal.type] || '🔮'
const confStr = `Conf ${signal.confidence}%`
const payload = JSON.stringify({
title: `${icon} ${ticker} ${dir}`,
body: `${confStr} • ${signal.description || ''}`.slice(0, 200),
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: `signal-${signal.id}`,
data: { symbol: signal.symbol, signalId: signal.id, type: signal.type },
vibrate: [200, 100, 200],
})
let sent = 0
for (const sub of subs) {
// Server-side filtering per subscriber preferences
try {
const filters = JSON.parse(sub.filters || '{}')
if (filters.minConfidence && signal.confidence < filters.minConfidence) continue
if (filters.minRatio && signal.type === 'volume_spike' &&
(signal.metadata?.ratio || 0) < filters.minRatio) continue
if (filters.types?.length) {
// oi_cvd signals have subType (oi_longs, oi_shorts, etc.) — check both
const subType = signal.type === 'oi_cvd' && signal.metadata?.subType
if (!filters.types.includes(signal.type) && !(subType && filters.types.includes(subType))) continue
}
if (filters.watchlistOnly && filters.watchlist?.length &&
!filters.watchlist.includes(signal.symbol)) continue
// Channel TF filter: skip if user disabled this timeframe
if (signal.type === 'channel' && filters.channelTimeframes?.length) {
const sigTf = signal.metadata?.interval
if (sigTf && !filters.channelTimeframes.includes(sigTf)) continue
}
} catch {}
const pushSub = {
endpoint: sub.endpoint,
keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth }
}
sent++
sendWithRetry(pushSub, payload, sub.endpoint)
}
if (sent > 0) {
log.info({ symbol: signal.symbol, type: signal.type, sent, total: subs.length }, 'Signal push sent')
}
}
function getVapidPublicKey() {
return process.env.VAPID_PUBLIC_KEY || null
}
/**
* Send push notification to a specific user (by user_id)
* Used for price alerts — only sends to subscriptions linked to this user
*/
function sendPushToUser(userId, payload) {
if (!_enabled || !_stmts) return
const subs = _stmts.getPushSubsByUser ? _stmts.getPushSubsByUser.all(userId) : []
if (!subs.length) return
const payloadStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
let sent = 0
for (const sub of subs) {
const pushSub = {
endpoint: sub.endpoint,
keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth }
}
sent++
sendWithRetry(pushSub, payloadStr, sub.endpoint)
}
if (sent > 0) {
log.info({ userId, sent }, 'Alert push sent')
}
}
function isEnabled() {
return _enabled
}
module.exports = { init, sendPushForSignal, sendPushToUser, getVapidPublicKey, isEnabled }
📜 Git History
7a5cb1ffix: drawing tools — touch support, future area, magnet snap, SW cache7 weeks ago
6d27024feat: structured pino logging + revert resync queue to simple handler8 weeks ago
e9524b7feat: Alerts tab + price alert drawing tool + deploy stability fixes8 weeks ago
bb0deb1fix: NATR cache race condition + push retry + funding stale fallback9 weeks ago
558881ffeat: channel signal settings — TF toggles (5m/15m/1h) + push filter9 weeks ago
6c58d95feat: OI Divergence + Funding Squeeze signals + OI ROC + funding gate9 weeks ago
354f8e3feat: Liquidity Sweep + Pin Bar signal detection, settings accordion UI, signal markers on charts, sort buttons9 weeks ago
33085a1feat: server-side Web Push notifications + time fixes3 months ago
Show last diff
Loading...