# AI 꾸러미 — 오프라인 (서비스 워커) — Service worker (offline cache)
> 빌드 때 만든 파일 목록으로 서비스 워커가 첫 화면 파일을 미리 받아 두고, 한 번 해 본 게임 파일은 저장해 인터넷이 끊겨도 다시 열리게 한다.  
> 견본: https://ai-techstudio.web.app/#t/u84

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 오프라인 (서비스 워커) — Service worker (offline cache)

#### 1. 목표
게임 모음 사이트에 오프라인을 넣어 줘 — 빌드 때 이번 파일 목록(shell · all)을 서비스 워커에 넣고, 설치할 때 첫 화면 파일을 미리 받기. 페이지는 네트워크 먼저, 해시가 붙은 /assets/ 는 저장본 먼저, 글꼴 CDN 은 저장본을 보이고 뒤에서 새로. 로그인 · 기록은 건드리지 않기. 한 번 해 본 게임만 저장.

#### 2. 핵심 기술 용어
- **Service worker (offline cache)** — 서비스 워커 — 페이지 대신 요청을 받아 저장본을 꺼내 줌
- **Precache manifest** — 빌드 때 미리 받을 파일 목록
- **Cache-first / network-first / stale-while-revalidate** — 저장본 먼저 · 네트워크 먼저 · 저장본 보이고 뒤에서 새로
- **Cache Storage API (caches.open)** — 브라우저 저장 창고

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 배포판(import.meta.env.PROD) · https 에서만 등록, 앱(네이티브)에서는 쓰지 않기
- 등록은 첫 화면이 다 뜬 뒤(load 이벤트) — 첫 로딩과 겨루지 않게
- /assets/ 는 저장본 먼저, 페이지는 네트워크 먼저 — 페이지까지 저장본 먼저면 새 버전이 안 보인다
- activate 에서 이번 빌드에 없는 옛 /assets/ 만 지우기
- GET 이 아닌 요청 · Firebase 같은 다른 출처는 건드리지 않기

#### 5. 완성 기준 (이게 보이면 성공)
- 한 번 열어 본 뒤 비행기 모드에서 새로 고침해도 첫 화면과 해 본 게임이 열린다
- 개발자 도구 Application → Cache Storage 에 mm-assets · mm-pages · mm-ext 가 보인다
- 새로 배포하면 탭을 모두 닫고 다시 열 때 새 버전이 뜨고, 옛 /assets/ 파일이 지워진다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 서비스 워커는 페이지와 따로 도는 스크립트 — 페이지의 fetch 를 가로채 저장본(Cache Storage)을 줄 수 있다.
- 빌드 도구가 sw.js 의 __SW_MANIFEST__ 자리를 이번 빌드 파일 목록으로 바꾼다: shell(첫 화면에 꼭) · all(이번 /assets/ 전부).
- install: shell 과 첫 페이지를 미리 받는다. activate: all 에 없는 옛 /assets/ 파일을 지운다.
- fetch: 페이지는 네트워크 먼저(안 되면 저장본), 해시 붙은 /assets/ 는 저장본 먼저(내용이 안 바뀜), 글꼴은 저장본 먼저 + 뒤에서 새로.
- 새 버전은 skipWaiting 하지 않고 열린 탭이 모두 닫힌 뒤 켜진다 — 열린 탭이 쓰던 옛 파일을 지우지 않게.

## 핵심 코드 — 설치 · 정리 · 요청마다 전략 고르기
(발췌: sw/sw.js 를 줄임 (등록은 src/services/offline.ts))
```js
const MANIFEST = __SW_MANIFEST__; // 빌드 때 { shell: [...], all: [...] } 로 바뀐다
const ASSETS = 'mm-assets';
const PAGES = 'mm-pages';
const ROOT = new URL(self.registration.scope).href;
const abs = (p) => new URL(p, ROOT).href;
const ALL = new Set(MANIFEST.all.map(abs));

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const assets = await caches.open(ASSETS);
    await assets.addAll(MANIFEST.shell.map((p) => new Request(abs(p), { cache: 'reload' })));
    await (await caches.open(PAGES)).add(new Request(ROOT, { cache: 'reload' }));
  })());
});

self.addEventListener('activate', (event) => {
  event.waitUntil((async () => {
    const assets = await caches.open(ASSETS);
    for (const req of await assets.keys())
      if (req.url.startsWith(abs('assets/')) && !ALL.has(req.url)) await assets.delete(req); // 옛 빌드 파일
    await self.clients.claim();
  })());
});

self.addEventListener('fetch', (event) => {
  const req = event.request;
  if (req.method !== 'GET') return;
  const url = new URL(req.url);
  if (req.mode === 'navigate' && url.origin === location.origin) event.respondWith(page(req)); // 네트워크 먼저
  else if (url.origin === location.origin && url.pathname.startsWith(new URL('assets/', ROOT).pathname))
    event.respondWith(cacheFirst(req, ASSETS)); // 해시 붙은 파일 — 저장본 먼저
});

async function cacheFirst(req, name) {
  const cache = await caches.open(name);
  const hit = await cache.match(req, { ignoreSearch: true, ignoreVary: true });
  if (hit) return hit;
  const res = await fetch(req);
  if (res.ok) await cache.put(req, res.clone());
  return res;
}
```

## 흔한 실수 · 확인 목록
- [ ] **페이지(index.html)를 저장본 먼저로 두면 새로 배포해도 옛 화면이 계속 뜬다** — 페이지는 네트워크 먼저, 해시가 붙어 내용이 안 바뀌는 /assets/ 만 저장본 먼저.
- [ ] **skipWaiting 으로 바로 켜면 열린 탭이 쓰던 옛 파일이 지워져 게임이 깨진다** — 새 버전은 탭이 모두 닫힌 뒤 켜지게 둔다.
- [ ] **서비스 워커가 생기기 전에 받은 첫 방문 파일은 저장되지 않는다** — 등록 뒤 performance.getEntriesByType('resource') 의 주소를 postMessage 로 보내 저장하게 한다 (offline.ts).
- [ ] **개발 서버에 등록하면 고친 파일 대신 저장본이 떠 헷갈린다** — import.meta.env.PROD 일 때만 등록한다.

## 완성 기준 체크리스트
- [ ] 한 번 열어 본 뒤 비행기 모드에서 새로 고침해도 첫 화면과 해 본 게임이 열린다
- [ ] 개발자 도구 Application → Cache Storage 에 mm-assets · mm-pages · mm-ext 가 보인다
- [ ] 새로 배포하면 탭을 모두 닫고 다시 열 때 새 버전이 뜨고, 옛 /assets/ 파일이 지워진다

## 이 기술 정보
- id: `u84` · 분류: 게임 시스템 · AI › 플랫폼 · 성능 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 첫 설치 때 shell 파일만 받는다. 게임은 해 본 것만 저장 — 저장 용량은 해 본 게임 수만큼.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u84
- 쓰면 좋을 때: 학교 · 행사장처럼 인터넷이 불안한 곳에서 쓰는 사이트 / 같은 게임을 여러 번 여는 사용자 — 두 번째부터 빨리 열림
- 쓰지 말 때: 개발 서버 — 파일이 계속 바뀌어 옛 저장본이 남는다 (배포판에서만 등록) / 로그인 · 기록 올리기 · 온라인 대전 요청 — 늘 네트워크로 (가로채지 않기)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u84 견본 항목 — `src/demos/demosSystem.ts:1349`
```ts
  u84: {
    kind: '2d',
    caption: '처음 한 번 목록대로 미리 받아 두면(위) 인터넷이 끊겨도 서비스 워커가 창고에서 꺼내 줘요(아래)',
    make() {
      const P = 10;
      const FILES = ['index.html', 'app.js', 'style.css', 'logo.png', 'sfx.mp3'];
      return {
        draw(g, w, h, t) {
          stage(g, w, h, SKY);
          const x = t % P;
          const online = x < 5;
          // 화면
          box(g, 8, 40, 70, 90, 8, '#fff', '#7ab0e0', 1.5);
          box(g, 8, 40, 70, 12, 4, '#d8eaff');
          const ok = online ? x > 3.4 : true;
          if (ok) {
            circle(g, 43, 86, 16, '#ffd23f');
            face(g, 43, 86, 12);
          } else txt(g, '불러오는 중', 43, 86, 8, '#7a8ab0', 'center', 700);
          txt(g, '게임 화면', 43, 140, 9, '#2a4a7a', 'center', 800);
          // 워커
          gear(g, 140, 60, 18, t * (online ? 1 : 2), '#5b6cff');
          txt(g, '서비스 워커', 140, 87, 9, '#3a49d8', 'center', 800);
          // 창고
          box(g, 104, 112, 74, 76, 8, '#fff6e0', '#e2ae3c', 1.5);
          txt(g, '창고 (캐시)', 141, 122, 8.5, '#8a5b10', 'center', 800);
          const stored = online ? Math.min(FILES.length, Math.floor(x / 0.6)) : FILES.length;
          for (let i = 0; i < stored; i++) {
            box(g, 110, 130 + i * 11, 62, 9, 3, '#ffe08f');
            txt(g, FILES[i]!, 141, 135 + i * 11, 6.5, '#6a4a10', 'center', 700, MONO);
          }
          // 인터넷
          cloud(g, 266, 64, 34, online ? '#9ad0ff' : '#cfd6e2');
          txt(g, '인터넷', 266, 70, 9, online ? '#1a4a8a' : '#8a92a8', 'center', 800);
          if (!online) cross(g, 266, 44, 8);
          // 와이파이 표시
          pill(g, online ? '와이파이 켬 · 미리 받기' : '와이파이 끔 · 오프라인', 160, 14, online ? '#2bb673' : '#ff5a5a', '#fff', 9);
          line(g, 80, 70, 120, 62, '#7ab0e0', 2);
          line(g, 160, 60, 230, 62, online ? '#7ab0e0' : '#cfd6e2', 2, online ? undefined : [3, 4]);
          line(g, 140, 95, 140, 111, '#e2ae3c', 2);
          // 흐르는 짐
          if (online && stored < FILES.length) {
            const p = (x % 0.6) / 0.6;
            const px = p < 0.6 ? lerp(240, 150, p / 0.6) : 150 - 10 * 0;
            const py = p < 0.6 ? 62 : lerp(70, 128 + stored * 11, (p - 0.6) / 0.4);
            box(g, px - 14, py - 5, 28, 10, 3, '#ffe08f', '#e2ae3c', 1);
          }
          if (!online) {
            const p = (x % 1.25) / 1.25;
            const pts: [number, number][] = [
              [78, 70],
              [140, 60],
              [140, 130],
              [140, 60],
              [78, 70],
            ];
            const seg = Math.min(3, Math.floor(p * 4));
            const q = p * 4 - seg;
            const a = pts[seg]!;
            const b = pts[seg + 1]!;
            circle(g, lerp(a[0], b[0], q), lerp(a[1], b[1], q), 4.5, seg < 2 ? '#5b6cff' : '#2bb673');
            if (seg >= 2) check(g, 96, 52, 5);
          }
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [접근성 · 움직임 줄이기](https://ai-techstudio.web.app/ai/t/u86.md) `u86`
- 참고 문서: [MDN — Service Worker API](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) · [MDN — Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache)
