[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
---
|
||||
id: web-pwa-service-worker
|
||||
title: PWA / Service Worker — Offline / Push / Install
|
||||
category: Coding
|
||||
status: draft
|
||||
source_trust_level: B
|
||||
verification_status: conceptual
|
||||
created_at: 2026-05-09
|
||||
updated_at: 2026-05-09
|
||||
tags: [web, pwa, service-worker, offline, vibe-coding]
|
||||
tech_stack: { language: "TS / Workbox", applicable_to: ["Frontend"] }
|
||||
applied_in: []
|
||||
aliases: [PWA, Service Worker, Workbox, manifest, install prompt, push notification web]
|
||||
---
|
||||
|
||||
# PWA / Service Worker
|
||||
|
||||
> **Web app 가 native 처럼**. Manifest + Service Worker = installable + offline + push. **Workbox** 가 표준 라이브러리. iOS 16.4+ web push 지원.
|
||||
|
||||
## 📖 핵심 개념
|
||||
- Manifest: 앱 메타 (이름, icon, theme).
|
||||
- Service Worker: 네트워크 인터셉트, 캐싱, push.
|
||||
- Cache API: 자원 저장.
|
||||
- Background sync: 오프라인 → 온라인 시 sync.
|
||||
|
||||
## 💻 코드 패턴
|
||||
|
||||
### Manifest
|
||||
```json
|
||||
// public/manifest.webmanifest
|
||||
{
|
||||
"name": "Acme",
|
||||
"short_name": "Acme",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3b82f6",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#3b82f6">
|
||||
<link rel="apple-touch-icon" href="/icon-192.png">
|
||||
```
|
||||
|
||||
### Service Worker (단순 cache)
|
||||
```ts
|
||||
// public/sw.js
|
||||
const CACHE = 'app-v1';
|
||||
const ASSETS = ['/', '/app.css', '/app.js'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
if (e.request.method !== 'GET') return;
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(r => r ?? fetch(e.request))
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Register
|
||||
```ts
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js').then(reg => {
|
||||
reg.addEventListener('updatefound', () => {
|
||||
const w = reg.installing;
|
||||
w?.addEventListener('statechange', () => {
|
||||
if (w.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
showUpdatePrompt(); // 새 버전 있음
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Workbox (high-level)
|
||||
```ts
|
||||
// workbox.config.ts (Vite plugin / Next config)
|
||||
import { generateSW } from 'workbox-build';
|
||||
|
||||
generateSW({
|
||||
swDest: 'dist/sw.js',
|
||||
globDirectory: 'dist',
|
||||
globPatterns: ['**/*.{js,css,html,png,jpg,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\.example\.com/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api',
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 5 * 60 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /\.(?:png|jpg|webp)$/,
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'images', expiration: { maxAgeSeconds: 30 * 24 * 3600 } },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Vite-PWA plugin
|
||||
```ts
|
||||
// vite.config.ts
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: { globPatterns: ['**/*.{js,css,html,png,svg}'] },
|
||||
manifest: { /* 위 */ },
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
### Install prompt
|
||||
```ts
|
||||
let deferred: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferred = e as BeforeInstallPromptEvent;
|
||||
showInstallButton();
|
||||
});
|
||||
|
||||
installBtn.onclick = async () => {
|
||||
if (!deferred) return;
|
||||
await deferred.prompt();
|
||||
const choice = await deferred.userChoice;
|
||||
console.log(choice.outcome); // 'accepted' / 'dismissed'
|
||||
deferred = null;
|
||||
};
|
||||
```
|
||||
|
||||
### Push notification
|
||||
```ts
|
||||
// 권한
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') return;
|
||||
|
||||
// Subscribe
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: VAPID_PUBLIC_KEY,
|
||||
});
|
||||
|
||||
// 서버에 보내 — DB 에 저장
|
||||
await fetch('/api/push/subscribe', { method: 'POST', body: JSON.stringify(sub) });
|
||||
```
|
||||
|
||||
```ts
|
||||
// SW
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? {};
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title, {
|
||||
body: data.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge.png',
|
||||
data: { url: data.url },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
event.waitUntil(self.clients.openWindow(event.notification.data.url));
|
||||
});
|
||||
```
|
||||
|
||||
### Server push (web-push)
|
||||
```ts
|
||||
import webpush from 'web-push';
|
||||
|
||||
webpush.setVapidDetails('mailto:admin@acme.com', VAPID_PUBLIC, VAPID_PRIVATE);
|
||||
|
||||
await webpush.sendNotification(subscription, JSON.stringify({
|
||||
title: 'New message',
|
||||
body: 'You have a new message',
|
||||
url: '/inbox',
|
||||
}));
|
||||
```
|
||||
|
||||
### Background sync
|
||||
```ts
|
||||
// SW
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-orders') {
|
||||
event.waitUntil(syncOrders());
|
||||
}
|
||||
});
|
||||
|
||||
// App
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
await reg.sync.register('sync-orders');
|
||||
// 오프라인 → 다음 online 에 자동
|
||||
```
|
||||
|
||||
### Update flow (smooth)
|
||||
```ts
|
||||
// 사용자에 새 버전 알림 → "Refresh" 버튼
|
||||
// 한번에 reload — 데이터 잃지 않게 자동 X
|
||||
```
|
||||
|
||||
```ts
|
||||
// Workbox skipWaiting
|
||||
import { Workbox } from 'workbox-window';
|
||||
const wb = new Workbox('/sw.js');
|
||||
wb.addEventListener('waiting', () => {
|
||||
if (confirm('New version available. Refresh?')) {
|
||||
wb.messageSkipWaiting();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 🤔 의사결정 기준
|
||||
| 사용 | 추천 |
|
||||
|---|---|
|
||||
| Static + cache | CacheFirst |
|
||||
| API + freshness | NetworkFirst (timeout fallback) |
|
||||
| Image / font | CacheFirst with TTL |
|
||||
| Real-time | Network only |
|
||||
| Offline-first | Workbox + IndexedDB |
|
||||
| Push | VAPID + web-push |
|
||||
|
||||
## ❌ 안티패턴
|
||||
- **모든 자원 cache + version 안 bump**: 새 버전 안 받음.
|
||||
- **API 까지 long cache**: stale 데이터.
|
||||
- **SW 무한 cache 자라남**: expiration / quota.
|
||||
- **Update 강제 (skipWaiting)**: 진행 중 작업 잃음.
|
||||
- **Push 권한 즉시 page load 시**: 사용자 거부 + 차단.
|
||||
- **VAPID key client 노출 (private)**: server only.
|
||||
- **iOS push 가정 — 안 install**: PWA 설치 후만 push 작동.
|
||||
|
||||
## 🤖 LLM 활용 힌트
|
||||
- Workbox 가 SW 직접 보다 단순.
|
||||
- VitePWA / next-pwa plugin.
|
||||
- Push = VAPID + web-push + iOS 설치 필요.
|
||||
|
||||
## 🔗 관련 문서
|
||||
- [[Web_HTTP_Cache_Headers]]
|
||||
- [[Web_Service_Worker_Patterns]]
|
||||
- [[iOS_Push_Notifications]]
|
||||
Reference in New Issue
Block a user