PageSourceSearch

https://naemo.ai/sw.js

js naemo.ai collected 2026-09-24 14:30:12 UTC 5,396 bytes, 140 lines download raw bytes

1/*
2 * 내모 서비스워커
3 *
4 * 설계 원칙 — 이 앱은 로그인된 교사의 학생 정보를 다룬다. 따라서 사용자 데이터가
5 * 담길 수 있는 응답은 "절대" 캐시하지 않는다. 캐시 대상은 배포마다 파일 이름이
6 * 바뀌는 정적 자원(_next/static)과 폰트·아이콘처럼 계정과 무관한 것뿐이다.
7 *
8 *  - 화면 이동(navigate) : 항상 네트워크. 실패했을 때만 오프라인 안내 화면.
9 *                          응답 자체는 캐시하지 않는다(HTML에 사용자 정보가 들어간다).
10 *  - /api/*, 서버 액션   : 손대지 않는다.
11 *  - RSC 요청(?_rsc=)    : 손대지 않는다. 화면 조각에 사용자 데이터가 들어간다.
12 *  - 정적 자원           : 캐시 우선. 파일명이 해시라 배포돼도 섞이지 않는다.
13 *  - 폰트·아이콘·이미지   : 캐시를 먼저 주고 뒤에서 조용히 갱신.
14 */
15
16const VERSION = 'v1';
17const PRECACHE = `naemo-precache-${VERSION}`;
18const ASSETS = `naemo-assets-${VERSION}`;
19const OFFLINE_URL = '/offline.html';
20
21// 오프라인 안내 화면은 로그인 상태와 무관한 정적 파일이라 미리 받아둬도 안전하다.
22const PRECACHE_URLS = [
23    OFFLINE_URL,
24    '/icons/icon-192.png',
25];
26
27self.addEventListener('install', (event) => {
28    event.waitUntil(
29        caches
30            .open(PRECACHE)
31            .then((cache) => cache.addAll(PRECACHE_URLS))
32            // 미리 받기에 실패해도 설치까지 막지는 않는다. 오프라인 화면이 없을 뿐
33            // 나머지 동작은 멀쩡해야 한다.
34            .catch(() => undefined)
35            .then(() => self.skipWaiting())
36    );
37});
38
39self.addEventListener('activate', (event) => {
40    event.waitUntil(
41        caches
42            .keys()
43            .then((keys) =>
44                Promise.all(
45                    keys
46                        .filter((key) => key.startsWith('naemo-') && key !== PRECACHE && key !== ASSETS)
47                        .map((key) => caches.delete(key))
48                )
49            )
50            .then(() => self.clients.claim())
51    );
52});
53
54/** 계정과 무관한, 캐시해도 되는 정적 자원인지 */
55function isCacheableAsset(url) {
56    if (url.pathname.startsWith('/_next/static/')) return true;
57    if (url.pathname.startsWith('/fonts/')) return true;
58    if (url.pathname.startsWith('/icons/')) return true;
59    // 사이드바 등의 애니메이션 아이콘(Lottie JSON). 계정과 무관한 정적 파일.
60    if (url.pathname.startsWith('/lordicons/')) return true;
61    return /\.(?:woff2?|ttf|otf|png|jpe?g|svg|webp|avif|ico)$/i.test(url.pathname);
62}
63
64/** 절대 건드리면 안 되는 경로인지 */
65function isOffLimits(url, request) {
66    if (url.pathname.startsWith('/api/')) return true;
67    // 결제는 외부 리다이렉트가 오가는 구간이라 중간에 끼어들지 않는다.
68    if (url.pathname.startsWith('/payment')) return true;
69    // React Server Components 응답 — 사용자 데이터가 들어있다.
70    if (url.searchParams.has('_rsc')) return true;
71    if (request.headers.get('RSC') === '1') return true;
72    return false;
73}
74
75self.addEventListener('fetch', (event) => {
76    const { request } = event;
77
78    // 조회가 아닌 요청(서버 액션 POST 등)은 그대로 흘려보낸다.
79    if (request.method !== 'GET') return;
80
81    let url;
82    try {
83        url = new URL(request.url);
84    } catch {
85        return;
86    }
87
88    if (url.origin !== self.location.origin) return;
89    if (isOffLimits(url, request)) return;
90
91    // 화면 이동: 네트워크만 쓰고, 실패하면 오프라인 안내를 보여준다.
92    if (request.mode === 'navigate') {
93        event.respondWith(
94            fetch(request).catch(async () => {
95                const cached = await caches.match(OFFLINE_URL, { cacheName: PRECACHE });
96                return (
97                    cached ||
98                    new Response('연결이 끊겼습니다.', {
99                        status: 503,
100                        headers: { 'Content-Type': 'text/plain; charset=utf-8' },
101                    })
102                );
103            })
104        );
105        return;
106    }
107
108    if (!isCacheableAsset(url)) return;
109
110    event.respondWith(
111        caches.open(ASSETS).then(async (cache) => {
112            const cached = await cache.match(request);
113
114            const network = fetch(request)
115                .then((response) => {
116                    // 정상 응답만 저장한다. 리다이렉트나 오류를 캐시에 넣으면
117                    // 다음부터 그 상태로 굳는다.
118                    if (response && response.status === 200 && response.type === 'basic') {
119                        cache.put(request, response.clone());
120                    }
121                    return response;
122                })
123                .catch(() => undefined);
124
125            // 캐시가 있으면 즉시 주고, 갱신은 뒤에서 진행한다.
126            if (cached) {
127                event.waitUntil(network);
128                return cached;
129            }
130
131            const response = await network;
132            return response || Response.error();
133        })
134    );
135});
136
137// 새 버전이 올라왔을 때 페이지가 요청하면 즉시 교체한다.
138self.addEventListener('message', (event) => {
139    if (event.data === 'SKIP_WAITING') self.skipWaiting();
140});

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.