PageSourceSearch

https://seodoon-church-admin.web.app/sw.js

js seodoon-church-admin.web.app collected 2026-10-03 12:37:37 UTC 8,894 bytes, 329 lines download raw bytes

1/**
2 * ChurchOS Service Worker
3 * 오프라인 지원 및 캐싱
4 */
5
6const CACHE_NAME = 'churchos-v4'
7const STATIC_CACHE = 'churchos-static-v4'
8const DYNAMIC_CACHE = 'churchos-dynamic-v4'
9
10// 정적 자원 (항상 캐시)
11const STATIC_ASSETS = [
12  '/',
13  '/index.html',
14  '/manifest.json',
15  '/pic/logo.png',
16]
17
18// API 경로 패턴 (캐시하지 않음)
19const API_PATTERNS = [
20  /^https:\/\/firestore\.googleapis\.com/,
21  /^https:\/\/firebase\.googleapis\.com/,
22  /^https:\/\/identitytoolkit\.googleapis\.com/,
23  /^https:\/\/securetoken\.googleapis\.com/,
24]
25
26// 캐시 우선 경로 패턴
27const CACHE_FIRST_PATTERNS = [
28  /\.(?:png|jpg|jpeg|gif|svg|webp|ico)$/,
29  /\.(?:woff2?|ttf|otf|eot)$/,
30  /\.(?:css|js)$/,
31]
32
33// ============================================
34// 설치 이벤트
35// ============================================
36self.addEventListener('install', (event) => {
37  console.log('[SW] Installing Service Worker...')
38
39  event.waitUntil(
40    caches.open(STATIC_CACHE)
41      .then((cache) => {
42        console.log('[SW] Caching static assets')
43        return cache.addAll(STATIC_ASSETS)
44      })
45      .then(() => {
46        console.log('[SW] Installation complete')
47        return self.skipWaiting()
48      })
49      .catch((error) => {
50        console.error('[SW] Installation failed:', error)
51      })
52  )
53})
54
55// ============================================
56// 활성화 이벤트
57// ============================================
58self.addEventListener('activate', (event) => {
59  console.log('[SW] Activating Service Worker...')
60
61  event.waitUntil(
62    caches.keys()
63      .then((cacheNames) => {
64        return Promise.all(
65          cacheNames
66            .filter((name) => {
67              // 현재 버전이 아닌 캐시 삭제
68              return name.startsWith('churchos-') &&
69                     name !== STATIC_CACHE &&
70                     name !== DYNAMIC_CACHE
71            })
72            .map((name) => {
73              console.log('[SW] Deleting old cache:', name)
74              return caches.delete(name)
75            })
76        )
77      })
78      .then(() => {
79        console.log('[SW] Activation complete')
80        return self.clients.claim()
81      })
82  )
83})
84
85// ============================================
86// Fetch 이벤트
87// ============================================
88self.addEventListener('fetch', (event) => {
89  const { request } = event
90  const url = new URL(request.url)
91
92  // http/https가 아닌 요청은 무시 (chrome-extension:// 등)
93  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
94    return
95  }
96
97  // 외부 CDN 요청은 서비스 워커가 처리하지 않음 (브라우저가 직접 처리)
98  const externalCDNs = [
99    'cdn.tailwindcss.com',
100    'www.gstatic.com',
101    'gstatic.com',
102    'cdnjs.cloudflare.com',
103    'cdn.jsdelivr.net',
104    'fonts.googleapis.com',
105    'fonts.gstatic.com',
106    'apis.google.com'
107  ]
108  if (externalCDNs.some(cdn => url.hostname.includes(cdn))) {
109    return
110  }
111
112  // API 요청은 네트워크 우선
113  if (isApiRequest(url.href)) {
114    event.respondWith(networkFirst(request))
115    return
116  }
117
118  // 정적 자원은 캐시 우선
119  if (isCacheFirstRequest(url.href)) {
120    event.respondWith(cacheFirst(request))
121    return
122  }
123
124  // 페이지 네비게이션은 네트워크 우선 + 오프라인 폴백
125  if (request.mode === 'navigate') {
126    event.respondWith(networkFirstWithFallback(request))
127    return
128  }
129
130  // 기타 요청은 Stale-While-Revalidate
131  event.respondWith(staleWhileRevalidate(request))
132})
133
134// ============================================
135// 캐싱 전략
136// ============================================
137
138/**
139 * 캐시 우선 전략 (정적 자원)
140 */
141async function cacheFirst(request) {
142  const cached = await caches.match(request)
143  if (cached) {
144    return cached
145  }
146
147  try {
148    const response = await fetch(request)
149    if (response.ok) {
150      const cache = await caches.open(STATIC_CACHE)
151      cache.put(request, response.clone())
152    }
153    return response
154  } catch (error) {
155    console.error('[SW] Cache first failed:', error)
156    return new Response('Offline', { status: 503 })
157  }
158}
159
160/**
161 * 네트워크 우선 전략 (API)
162 */
163async function networkFirst(request) {
164  try {
165    const response = await fetch(request)
166    return response
167  } catch (error) {
168    const cached = await caches.match(request)
169    if (cached) {
170      return cached
171    }
172    return new Response(JSON.stringify({ error: 'Offline' }), {
173      status: 503,
174      headers: { 'Content-Type': 'application/json' }
175    })
176  }
177}
178
179/**
180 * 네트워크 우선 + 오프라인 폴백 (페이지)
181 */
182async function networkFirstWithFallback(request) {
183  try {
184    // HTTP 캐시를 우회해 항상 최신 index.html을 받아온다 (새 배포 즉시 반영)
185    const response = await fetch(request, { cache: 'reload' })
186    if (response.ok) {
187      const cache = await caches.open(DYNAMIC_CACHE)
188      cache.put(request, response.clone())
189    }
190    return response
191  } catch (error) {
192    const cached = await caches.match(request)
193    if (cached) {
194      return cached
195    }
196
197    // 오프라인 폴백 페이지
198    const fallbackResponse = await caches.match('/index.html')
199    if (fallbackResponse) {
200      return fallbackResponse
201    }
202
203    return new Response(getOfflinePage(), {
204      status: 503,
205      headers: { 'Content-Type': 'text/html; charset=utf-8' }
206    })
207  }
208}
209
210/**
211 * Stale-While-Revalidate 전략
212 */
213async function staleWhileRevalidate(request) {
214  const cached = await caches.match(request)
215
216  const fetchPromise = fetch(request)
217    .then(async (response) => {
218      if (response.ok) {
219        try {
220          const responseClone = response.clone()
221          const cache = await caches.open(DYNAMIC_CACHE)
222          await cache.put(request, responseClone)
223        } catch (e) {
224          console.log('[SW] Cache put failed:', e)
225        }
226      }
227      return response
228    })
229    .catch(() => cached)
230
231  return cached || fetchPromise
232}
233
234// ============================================
235// 헬퍼 함수
236// ============================================
237
238function isApiRequest(url) {
239  return API_PATTERNS.some((pattern) => pattern.test(url))
240}
241
242function isCacheFirstRequest(url) {
243  return CACHE_FIRST_PATTERNS.some((pattern) => pattern.test(url))
244}
245
246function getOfflinePage() {
247  return `
248    <!DOCTYPE html>
249    <html lang="ko">
250    <head>
251      <meta charset="UTF-8">
252      <meta name="viewport" content="width=device-width, initial-scale=1.0">
253      <title>오프라인 - ChurchOS</title>
254      <style>
255        * { margin: 0; padding: 0; box-sizing: border-box; }
256        body {
257          font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
258          background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
259          min-height: 100vh;
260          display: flex;
261          align-items: center;
262          justify-content: center;
263          color: white;
264          text-align: center;
265          padding: 20px;
266        }
267        .container {
268          max-width: 400px;
269        }
270        .icon {
271          font-size: 64px;
272          margin-bottom: 24px;
273        }
274        h1 {
275          font-size: 24px;
276          margin-bottom: 12px;
277        }
278        p {
279          font-size: 16px;
280          opacity: 0.9;
281          margin-bottom: 24px;
282          line-height: 1.5;
283        }
284        button {
285          background: white;
286          color: #1e3a5f;
287          border: none;
288          padding: 12px 24px;
289          border-radius: 8px;
290          font-size: 16px;
291          font-weight: 600;
292          cursor: pointer;
293          transition: transform 0.2s;
294        }
295        button:hover {
296          transform: scale(1.05);
297        }
298      </style>
299    </head>
300    <body>
301      <div class="container">
302        <div class="icon">📡</div>
303        <h1>인터넷 연결 없음</h1>
304        <p>현재 오프라인 상태입니다. 인터넷 연결을 확인하고 다시 시도해 주세요.</p>
305        <button onclick="location.reload()">다시 시도</button>
306      </div>
307    </body>
308    </html>
309  `
310}
311
312// ============================================
313// 메시지 이벤트 (앱과 통신)
314// ============================================
315self.addEventListener('message', (event) => {
316  if (event.data && event.data.type === 'SKIP_WAITING') {
317    self.skipWaiting()
318  }
319
320  if (event.data && event.data.type === 'CLEAR_CACHE') {
321    event.waitUntil(
322      caches.keys().then((names) => {
323        return Promise.all(names.map((name) => caches.delete(name)))
324      })
325    )
326  }
327})
328
329console.log('[SW] Service Worker loaded')

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.