/** * GUARDiA ITSM — Service Worker (F-4 Mobile PWA) * * 전략: * - 정적 자산 : Cache-First (빠른 로딩) * - API 요청 : Network-First (최신 데이터 우선, 오프라인 시 캐시) * - 오프라인 : /static/offline.html 폴백 * * 캐시 버전이 바뀌면 이전 캐시를 자동 정리합니다. */ const CACHE_VERSION = 'guardia-v1'; const STATIC_CACHE = `${CACHE_VERSION}-static`; const API_CACHE = `${CACHE_VERSION}-api`; const OFFLINE_URL = '/static/offline.html'; // 설치 시 사전 캐싱할 정적 자산 const PRECACHE_URLS = [ '/', '/static/offline.html', '/static/manifest.json', '/static/style.css', '/static/app.js', '/static/login.html', '/static/login.css', '/static/login.js', '/static/index.html', '/static/customer.html', ]; // API 캐시 대상 접두사 const API_CACHE_PREFIXES = [ '/api/dashboard', '/api/sr', '/api/incidents', '/api/metrics/summary', '/api/metrics/health', ]; // 캐시하지 않을 API 경로 (실시간·민감 데이터) const NO_CACHE_PATTERNS = [ '/api/auth', '/api/audit', '/api/pam', '/api/otp', '/ws', ]; // ── 설치 (Install) ───────────────────────────────────────────────────────── self.addEventListener('install', event => { event.waitUntil( caches.open(STATIC_CACHE) .then(cache => cache.addAll(PRECACHE_URLS).catch(() => { // 일부 파일이 없어도 SW 설치 계속 진행 return Promise.resolve(); })) .then(() => self.skipWaiting()) ); }); // ── 활성화 (Activate) — 이전 캐시 정리 ───────────────────────────────────── self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(keys => Promise.all( keys .filter(k => k.startsWith('guardia-') && k !== STATIC_CACHE && k !== API_CACHE) .map(k => caches.delete(k)) ) ).then(() => self.clients.claim()) ); }); // ── Fetch 인터셉트 ───────────────────────────────────────────────────────── self.addEventListener('fetch', event => { const { request } = event; const url = new URL(request.url); // 다른 오리진 요청은 처리하지 않음 (Ollama 등 내부 서비스 포함) if (url.origin !== self.location.origin) return; // WebSocket / POST/PUT/DELETE → 캐시 안 함 if (request.method !== 'GET') return; // 보안 민감 경로 → 캐시 안 함 if (NO_CACHE_PATTERNS.some(p => url.pathname.startsWith(p))) return; // API 요청 → Network-First if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request, API_CACHE)); return; } // 정적 자산 → Cache-First event.respondWith(cacheFirst(request, STATIC_CACHE)); }); // ── 전략 함수 ────────────────────────────────────────────────────────────── /** * Cache-First: 캐시 → 네트워크 → 오프라인 폴백 */ async function cacheFirst(request, cacheName) { const cached = await caches.match(request); if (cached) return cached; try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); } return response; } catch { const offline = await caches.match(OFFLINE_URL); return offline || new Response('오프라인 상태입니다.', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); } } /** * Network-First: 네트워크 → 캐시 → 오프라인 폴백 * API 응답은 5분간 캐시 유지 */ async function networkFirst(request, cacheName) { try { const response = await fetch(request); if (response.ok) { const cache = await caches.open(cacheName); // API 응답에 캐시 타임스탬프 추가 const cloned = response.clone(); cache.put(request, cloned); } return response; } catch { const cached = await caches.match(request); if (cached) return cached; // HTML 요청 시 오프라인 페이지 if (request.headers.get('Accept')?.includes('text/html')) { const offline = await caches.match(OFFLINE_URL); if (offline) return offline; } return new Response( JSON.stringify({ error: '오프라인 상태입니다.', offline: true }), { status: 503, headers: { 'Content-Type': 'application/json; charset=utf-8' }, } ); } } // ── 백그라운드 동기화 ────────────────────────────────────────────────────── self.addEventListener('sync', event => { if (event.tag === 'guardia-sync-sr') { event.waitUntil(syncPendingSR()); } }); async function syncPendingSR() { // IndexedDB에서 오프라인 중 생성된 SR 대기열 처리 (향후 구현) // 현재는 로그만 기록 console.log('[GUARDiA SW] 오프라인 SR 동기화 시작'); } // ── 푸시 알림 ────────────────────────────────────────────────────────────── self.addEventListener('push', event => { if (!event.data) return; let data; try { data = event.data.json(); } catch { data = { title: 'GUARDiA 알림', body: event.data.text() }; } const options = { body: data.body || '새 알림이 있습니다.', icon: '/static/icons/icon-192.png', badge: '/static/icons/icon-72.png', tag: data.tag || 'guardia-notification', data: data.data || {}, actions: data.actions || [ { action: 'view', title: '확인' }, { action: 'dismiss', title: '닫기' }, ], requireInteraction: data.severity === 'CRITICAL', }; event.waitUntil( self.registration.showNotification(data.title || 'GUARDiA ITSM', options) ); }); self.addEventListener('notificationclick', event => { event.notification.close(); if (event.action === 'dismiss') return; const url = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { for (const client of clientList) { if (client.url === url && 'focus' in client) return client.focus(); } return clients.openWindow(url); }) ); });