M9: PWA — 홈 화면에 추가, 오프라인 안내
- 매니페스트·아이콘(192/512/maskable/apple-touch, scripts/pwa-icons.ts로 다시 만들기) - 손으로 쓴 서비스 워커(apps/web/sw.js): 빌드 때 Vite 플러그인이 빌드 ID를 넣음. 페이지는 네트워크만(오프라인이면 offline.html), /assets/*만 캐시 먼저(200개까지), /api·/ws·/healthz·/internal은 손대지 않음 - 오프라인 안내 페이지, 방 밖에서도 "인터넷 연결이 없어요" 띠 - 홈 아래 "홈 화면에 추가" 카드(설치 버튼, iOS는 공유 메뉴 안내, 닫으면 기억) - 테스트: apps/web/src/sw.test.ts, e2e/pwa.e2e.ts(E2E_PORT), a11y.e2e.ts도 E2E_PORT 읽기 - 문서: 07 §11, 11 배포(no-cache 대상), README 확인 목록 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
68
apps/web/sw.js
Normal file
68
apps/web/sw.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Service worker (docs/07 §11). Built to dist/sw.js by vite.config.ts, which fills in VERSION with a build id,
|
||||
* so every deploy with changed files installs a new worker.
|
||||
* - Page loads: network first, never cached (index.html is always fresh online); offline → /offline.html.
|
||||
* - /assets/* (hashed, immutable): cache first, at most MAX_ASSETS entries.
|
||||
* - /api, /ws, /healthz, /internal and other origins: not touched at all.
|
||||
*/
|
||||
const VERSION = '__BUILD__';
|
||||
const SHELL = `bg-shell-${VERSION}`;
|
||||
const ASSETS = 'bg-assets-v1'; // hashed files never change, so they survive deploys
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
const MAX_ASSETS = 200;
|
||||
const BYPASS = /^\/(api|ws|healthz|internal)(\/|$)/;
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(SHELL)
|
||||
.then((cache) => cache.add(new Request(OFFLINE_URL, { cache: 'reload' })))
|
||||
.then(() => self.skipWaiting()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const key of await caches.keys()) {
|
||||
if (key.startsWith('bg-') && key !== SHELL && key !== ASSETS) await caches.delete(key);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== 'GET') return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin || BYPASS.test(url.pathname)) return;
|
||||
if (req.mode === 'navigate') event.respondWith(page(req));
|
||||
else if (url.pathname.startsWith('/assets/')) event.respondWith(asset(event, req));
|
||||
});
|
||||
|
||||
async function page(req) {
|
||||
try {
|
||||
return await fetch(req);
|
||||
} catch {
|
||||
const offline = await caches.match(OFFLINE_URL, { cacheName: SHELL });
|
||||
return offline ?? new Response('인터넷 연결이 없어요.', { status: 503, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
}
|
||||
}
|
||||
|
||||
async function asset(event, req) {
|
||||
const cache = await caches.open(ASSETS);
|
||||
const hit = await cache.match(req);
|
||||
if (hit) return hit;
|
||||
const res = await fetch(req);
|
||||
// A missing file falls back to index.html on the server; never keep that (or partial/error responses).
|
||||
if (res.status === 200 && !(res.headers.get('Content-Type') ?? '').startsWith('text/html')) {
|
||||
event.waitUntil(cache.put(req, res.clone()).then(() => trim(cache)));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
async function trim(cache) {
|
||||
const keys = await cache.keys();
|
||||
for (const key of keys.slice(0, Math.max(0, keys.length - MAX_ASSETS))) await cache.delete(key);
|
||||
}
|
||||
Reference in New Issue
Block a user