8d00d71072
카카오톡처럼 PC에 OS 토스트가 뜨도록 Web Notifications(Level1) +
Web Push/서비스워커(Level2)를 기존 인앱·SSE 알림 위에 얹는다.
중복 방지: 페이지가 열려 있으면 SSE 로컬 알림(포커스면 인앱만),
닫혀 있으면 서비스워커 푸시가 담당한다.
- 백엔드: push_subscriptions 엔티티 + WebPushService(VAPID 설정·
type→{title,body,url} 포매터·만료 구독 정리), notify()가 SSE와
함께 푸시 발송, push/subscribe·unsubscribe 엔드포인트, 마이그레이션,
web-push 의존성
- 프론트: public/sw.js(push/클릭), usePush 컴포저블(권한·구독, 결과
안내), notification.store 로컬 토스트, AppShell 'PC 알림 켜기' 버튼
- env/compose: VAPID 키 주입(공개키→프론트, 비밀키→백엔드)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
72 lines
2.1 KiB
JavaScript
72 lines
2.1 KiB
JavaScript
/* eslint-disable */
|
|
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
|
|
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
|
|
|
|
self.addEventListener('install', () => {
|
|
// 새 워커를 즉시 활성화
|
|
self.skipWaiting()
|
|
})
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(self.clients.claim())
|
|
})
|
|
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return
|
|
let payload = {}
|
|
try {
|
|
payload = event.data.json()
|
|
} catch (e) {
|
|
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
|
|
}
|
|
const title = payload.title || 'Relay'
|
|
const body = payload.body || ''
|
|
const url = payload.url || null
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
|
|
const clients = await self.clients.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true,
|
|
})
|
|
if (clients.length > 0) return
|
|
await self.registration.showNotification(title, {
|
|
body,
|
|
icon: '/relay-icon-256.png',
|
|
badge: '/relay-icon-256.png',
|
|
data: { url },
|
|
})
|
|
})(),
|
|
)
|
|
})
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close()
|
|
const url = (event.notification.data && event.notification.data.url) || '/'
|
|
event.waitUntil(
|
|
(async () => {
|
|
const clients = await self.clients.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true,
|
|
})
|
|
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
|
|
for (const client of clients) {
|
|
if ('focus' in client) {
|
|
await client.focus()
|
|
if ('navigate' in client && url) {
|
|
try {
|
|
await client.navigate(url)
|
|
} catch (e) {
|
|
/* 이동 실패 무시 */
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
// 열린 창이 없으면 새 창
|
|
if (self.clients.openWindow) await self.clients.openWindow(url)
|
|
})(),
|
|
)
|
|
})
|