diff --git a/README.md b/README.md index 4949b68..d8dd870 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chro bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 bun e2e/games.e2e.ts [게임id...] # 게임별: 방 만들기 → 친구 입장 → 실제 화면 조작으로 여러 턴(일부는 끝까지) bun e2e/a11y.e2e.ts # 접근성(axe) 검사 +bun e2e/pwa.e2e.ts # PWA: 매니페스트·아이콘, 서비스 워커, 오프라인 안내, 홈 화면에 추가 bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) ``` diff --git a/apps/web/index.html b/apps/web/index.html index 9362ef9..b7b3459 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,12 @@ + + + + + + 같이 놀자 보드게임 diff --git a/apps/web/public/icons/apple-touch-icon.png b/apps/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..336f8ac Binary files /dev/null and b/apps/web/public/icons/apple-touch-icon.png differ diff --git a/apps/web/public/icons/icon-192.png b/apps/web/public/icons/icon-192.png new file mode 100644 index 0000000..c79a74d Binary files /dev/null and b/apps/web/public/icons/icon-192.png differ diff --git a/apps/web/public/icons/icon-512.png b/apps/web/public/icons/icon-512.png new file mode 100644 index 0000000..bad89da Binary files /dev/null and b/apps/web/public/icons/icon-512.png differ diff --git a/apps/web/public/icons/icon-maskable-512.png b/apps/web/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..e930484 Binary files /dev/null and b/apps/web/public/icons/icon-maskable-512.png differ diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest new file mode 100644 index 0000000..42fda8c --- /dev/null +++ b/apps/web/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "id": "/", + "name": "같이 놀자 보드게임", + "short_name": "같이 놀자", + "description": "광고 없이 가볍게, 링크 하나로 친구·가족과 바로 즐기는 온라인 보드게임", + "lang": "ko", + "dir": "ltr", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f7f5f0", + "theme_color": "#f7f5f0", + "categories": ["games", "entertainment"], + "icons": [ + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/apps/web/public/offline.html b/apps/web/public/offline.html new file mode 100644 index 0000000..9b5bdae --- /dev/null +++ b/apps/web/public/offline.html @@ -0,0 +1,95 @@ + + + + + + + + 인터넷 연결이 없어요 · 같이 놀자 보드게임 + + + +
+
+ +

인터넷 연결이 없어요

+

와이파이나 데이터가 켜져 있는지 확인한 뒤 [다시 시도]를 눌러 주세요.

+ 다시 시도 +
+
+ + diff --git a/apps/web/src/components/InstallCard.tsx b/apps/web/src/components/InstallCard.tsx new file mode 100644 index 0000000..774cb5d --- /dev/null +++ b/apps/web/src/components/InstallCard.tsx @@ -0,0 +1,28 @@ +/** "홈 화면에 추가" offer (docs/07 §11). Hidden when already installed, dismissed, or not installable. */ +import { dismissInstall, promptInstall, useInstallMode } from '../lib/pwa'; + +export function InstallCard() { + const mode = useInstallMode(); + if (!mode) return null; + return ( +
+ + 📲 + +
+ 홈 화면에 추가해 보세요 +
+ {mode === 'ios' ? 'Safari 공유 버튼(네모에 화살표)을 누르고 [홈 화면에 추가]를 골라 주세요.' : '앱처럼 아이콘을 눌러 바로 열 수 있어요.'} +
+
+ {mode === 'prompt' && ( + + )} + +
+ ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 345eb32..547227a 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -5,6 +5,7 @@ import { formatRoomCode } from '@bg/shared/lite'; import { useToasts, toast } from '../store/toast'; import { useRoom } from '../net/socket'; import { haptic } from '../lib/feedback'; +import { useOnline } from '../lib/pwa'; const AVATAR_COLORS = ['#2563eb', '#db2777', '#16a34a', '#ea580c', '#7c3aed', '#0891b2', '#ca8a04', '#dc2626']; @@ -138,7 +139,8 @@ export function Toasts() { export function ConnectionBanner() { const status = useRoom((s) => s.status); const code = useRoom((s) => s.code); - if (!code) return null; + const online = useOnline(); + if (!code) return online ? null :
인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.
; if (status === 'reconnecting' || status === 'connecting') return
다시 연결하는 중…
; if (status === 'offline') return
인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.
; return null; diff --git a/apps/web/src/lib/pwa.ts b/apps/web/src/lib/pwa.ts new file mode 100644 index 0000000..e7e10b8 --- /dev/null +++ b/apps/web/src/lib/pwa.ts @@ -0,0 +1,93 @@ +/** PWA glue (docs/07 §11): service worker registration, "홈 화면에 추가", online state. */ +import { useSyncExternalStore } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; +} + +const DISMISS_KEY = 'bg.installDismissed'; +let deferred: BeforeInstallPromptEvent | null = null; +let dismissed = false; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((l) => l()); + +/** Call once at startup, before React renders: the install event can fire very early. */ +export function initPwa(): void { + try { + dismissed = localStorage.getItem(DISMISS_KEY) === '1'; + } catch { + // Storage blocked: just show the offer again next time. + } + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); // we offer our own button instead of the browser's mini bar + deferred = e as BeforeInstallPromptEvent; + emit(); + }); + window.addEventListener('appinstalled', () => { + deferred = null; + emit(); + }); + // Dev builds never register: Vite serves unhashed modules that must not be cached. + if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => void navigator.serviceWorker.register('/sw.js').catch(() => {})); + } +} + +function standalone(): boolean { + return matchMedia('(display-mode: standalone)').matches || (navigator as { standalone?: boolean }).standalone === true; +} + +function ios(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); +} + +/** 'prompt' = the browser can install with one tap, 'ios' = show Safari share-menu steps, null = hide. */ +export type InstallMode = 'prompt' | 'ios' | null; + +function installMode(): InstallMode { + if (dismissed || standalone()) return null; + if (deferred) return 'prompt'; + return ios() ? 'ios' : null; +} + +const subscribe = (l: () => void) => { + listeners.add(l); + return () => void listeners.delete(l); +}; + +export function useInstallMode(): InstallMode { + return useSyncExternalStore(subscribe, installMode, () => null); +} + +export async function promptInstall(): Promise { + const e = deferred; + if (!e) return; + deferred = null; // a prompt event can be used only once + emit(); + await e.prompt(); + await e.userChoice; +} + +export function dismissInstall(): void { + dismissed = true; + try { + localStorage.setItem(DISMISS_KEY, '1'); + } catch { + // ignore + } + emit(); +} + +const subscribeOnline = (l: () => void) => { + window.addEventListener('online', l); + window.addEventListener('offline', l); + return () => { + window.removeEventListener('online', l); + window.removeEventListener('offline', l); + }; +}; + +export function useOnline(): boolean { + return useSyncExternalStore(subscribeOnline, () => navigator.onLine, () => true); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 2a9444b..043110d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import { useSession } from './store/session'; import { toast } from './store/toast'; import { Home } from './pages/Home'; import { SiteHeader } from './components/SiteHeader'; +import { initPwa } from './lib/pwa'; const JoinPage = lazy(() => import('./pages/JoinPage')); const RoomPage = lazy(() => import('./pages/RoomPage')); @@ -75,6 +76,7 @@ function Shell() { ); } +initPwa(); createRoot(document.getElementById('root')!).render( diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index ddcf1c7..4df8abb 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -6,6 +6,7 @@ import { Sheet } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; import { CodeInput } from '../components/CodeInput'; import { GameGrid } from '../components/GameGrid'; +import { InstallCard } from '../components/InstallCard'; import { gameName } from '../components/GameNotice'; import { api } from '../net/api'; import { useSession } from '../store/session'; @@ -110,6 +111,8 @@ export function Home() { + + {picker && ( setPicker(false)} label="게임 고르기">

어떤 게임을 할까요?

diff --git a/apps/web/src/sw.test.ts b/apps/web/src/sw.test.ts new file mode 100644 index 0000000..ee96248 --- /dev/null +++ b/apps/web/src/sw.test.ts @@ -0,0 +1,137 @@ +/** Service worker cache rules (docs/07 §11), run against fake caches/fetch. */ +import { beforeEach, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ORIGIN = 'https://game.test'; +const SRC = readFileSync(join(import.meta.dir, '../sw.js'), 'utf8'); + +class FakeCache { + map = new Map(); + async match(req: { url: string } | string) { + return this.map.get(typeof req === 'string' ? new URL(req, ORIGIN).href : req.url)?.clone(); + } + async put(req: { url: string }, res: Response) { + this.map.set(req.url, res); + } + async add(req: Request) { + this.put(req, await net(req)); + } + async keys() { + return [...this.map.keys()].map((url) => ({ url })); + } + async delete(req: { url: string }) { + return this.map.delete(req.url); + } +} + +let stores: Map; +let net: (req: { url: string }) => Promise; +let fetched: string[]; +let handlers: Record void>; + +const caches = { + open: async (name: string) => stores.get(name) ?? stores.set(name, new FakeCache()).get(name)!, + keys: async () => [...stores.keys()], + delete: async (name: string) => stores.delete(name), + match: async (req: string, opts: { cacheName: string }) => stores.get(opts.cacheName)?.match(req), +}; +const fetchFn = (req: { url: string }) => { + fetched.push(new URL(req.url).pathname); + return net(req); +}; +class RelRequest extends Request { + constructor(url: string, init?: RequestInit) { + super(new URL(url, ORIGIN), init); + } +} + +beforeEach(() => { + stores = new Map(); + fetched = []; + handlers = {}; + net = async (req) => new Response(`body of ${new URL(req.url).pathname}`, { headers: { 'Content-Type': 'text/javascript' } }); + const self = { + location: new URL(ORIGIN), + addEventListener: (type: string, fn: (e: unknown) => void) => (handlers[type] = fn), + skipWaiting: async () => {}, + clients: { claim: async () => {} }, + }; + new Function('self', 'caches', 'fetch', 'Request', SRC)(self, caches, fetchFn, RelRequest); +}); + +async function lifecycle(type: 'install' | 'activate') { + const pending: Promise[] = []; + handlers[type]!({ waitUntil: (p: Promise) => pending.push(p) }); + await Promise.all(pending); +} + +/** Dispatches a fetch; returns undefined when the worker leaves the request to the browser. */ +async function request(path: string, opts: { mode?: string; method?: string; origin?: string } = {}) { + let res: Promise | undefined; + const pending: Promise[] = []; + handlers.fetch!({ + request: { url: new URL(path, opts.origin ?? ORIGIN).href, method: opts.method ?? 'GET', mode: opts.mode ?? 'cors' }, + respondWith: (p: Promise) => (res = p), + waitUntil: (p: Promise) => pending.push(p), + }); + const out = await res; + await Promise.all(pending); + return out; +} + +test('install keeps the offline page; activate drops old caches only', async () => { + stores.set('bg-shell-old', new FakeCache()); + stores.set('bg-assets-v0', new FakeCache()); + stores.set('someone-else', new FakeCache()); + await lifecycle('install'); + await lifecycle('activate'); + const names = [...stores.keys()]; + expect(names).toContain('someone-else'); + expect(names.filter((n) => n.startsWith('bg-'))).toEqual([expect.stringMatching(/^bg-shell-/)]); + expect(await (await caches.match('/offline.html', { cacheName: names.find((n) => n.startsWith('bg-shell-'))! }))?.text()).toBe('body of /offline.html'); +}); + +test('API, WebSocket, health, internal, non-GET and other origins are never handled', async () => { + await lifecycle('install'); + for (const path of ['/api/me', '/api/admin/stats', '/ws', '/healthz', '/internal/metrics']) { + expect(await request(path)).toBeUndefined(); + expect(await request(path, { mode: 'navigate' })).toBeUndefined(); + } + expect(await request('/assets/a.js', { method: 'POST' })).toBeUndefined(); + expect(await request('/assets/a.js', { origin: 'https://cdn.discordapp.com' })).toBeUndefined(); + expect([...stores.keys()].filter((n) => n.startsWith('bg-assets'))).toEqual([]); +}); + +test('pages come from the network and are never cached; offline shows the offline page', async () => { + await lifecycle('install'); + net = async () => new Response('v1', { headers: { 'Content-Type': 'text/html' } }); + expect(await (await request('/r/123456', { mode: 'navigate' }))!.text()).toBe('v1'); + net = async () => new Response('v2', { headers: { 'Content-Type': 'text/html' } }); + expect(await (await request('/', { mode: 'navigate' }))!.text()).toBe('v2'); + net = () => Promise.reject(new TypeError('Failed to fetch')); + expect(await (await request('/', { mode: 'navigate' }))!.text()).toBe('body of /offline.html'); + expect([...stores.values()].flatMap((c) => [...c.map.keys()]).map((u) => new URL(u).pathname)).toEqual(['/offline.html']); +}); + +test('hashed assets are cache-first; HTML fallbacks and partial responses are not kept', async () => { + expect(await (await request('/assets/a-1.js'))!.text()).toBe('body of /assets/a-1.js'); + expect(await (await request('/assets/a-1.js'))!.text()).toBe('body of /assets/a-1.js'); + expect(fetched).toEqual(['/assets/a-1.js']); + + net = async () => new Response('', { headers: { 'Content-Type': 'text/html; charset=utf-8' } }); + await request('/assets/gone.js'); + net = async () => new Response('part', { status: 206, headers: { 'Content-Type': 'audio/mpeg' } }); + await request('/assets/s.mp3'); + net = async () => new Response('err', { status: 500 }); + await request('/assets/b.js'); + expect([...stores.get('bg-assets-v1')!.map.keys()].map((u) => new URL(u).pathname)).toEqual(['/assets/a-1.js']); +}); + +test('asset cache is bounded, oldest out first', async () => { + for (let i = 0; i < 205; i++) await request(`/assets/f${i}.js`); + const kept = [...stores.get('bg-assets-v1')!.map.keys()].map((u) => new URL(u).pathname); + expect(kept.length).toBe(200); + expect(kept[0]).toBe('/assets/f5.js'); + expect(kept.at(-1)).toBe('/assets/f204.js'); +}); diff --git a/apps/web/sw.js b/apps/web/sw.js new file mode 100644 index 0000000..b1e863d --- /dev/null +++ b/apps/web/sw.js @@ -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); +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a08e545..3df0ccd 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,8 +1,26 @@ -import { defineConfig } from 'vite'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { defineConfig, type Plugin } from 'vite'; import react from '@vitejs/plugin-react'; +/** Emits dist/sw.js with a build id, so a deploy that changes files installs a new worker (docs/07 §11). */ +function serviceWorker(): Plugin { + return { + name: 'bg-service-worker', + apply: 'build', + generateBundle(_, bundle) { + const src = readFileSync(new URL('./sw.js', import.meta.url), 'utf8'); + const offline = readFileSync(new URL('./public/offline.html', import.meta.url), 'utf8'); + const files = Object.keys(bundle).sort().join('\n'); + const build = createHash('sha256').update(src).update(offline).update(files).digest('hex').slice(0, 12); + if (!src.includes("'__BUILD__'")) throw new Error('sw.js: VERSION placeholder missing'); + this.emitFile({ type: 'asset', fileName: 'sw.js', source: src.replace("'__BUILD__'", `'${build}'`) }); + }, + }; +} + export default defineConfig({ - plugins: [react()], + plugins: [react(), serviceWorker()], server: { port: 5173, proxy: { diff --git a/docs/07-ui-ux.md b/docs/07-ui-ux.md index 120d68a..9a3f215 100644 --- a/docs/07-ui-ux.md +++ b/docs/07-ui-ux.md @@ -112,7 +112,7 @@ | `ConfirmSheet` | 아래에서 올라오는 확인창(큰 버튼 두 개) | | `Card` | 트럼프/화투/게임 카드 공용 SVG, 크기 3단계 | | `RulesSheet` | 게임 중 규칙 요약(그림), 현재 상황 도움말 | -| `ConnectionBanner` | "다시 연결하는 중…" 띠 | +| `ConnectionBanner` | "다시 연결하는 중…" 띠, 인터넷이 끊기면 방 밖에서도 "인터넷 연결이 없어요" 띠 | ## 5. 초보자 도움 - 처음 하는 게임이면(기기 기준) 시작할 때 "30초 규칙 요약" 카드 3장(그림 위주), [건너뛰기]. @@ -155,3 +155,20 @@ - 결과 화면(승/패/무승부, 한 판 더 투표 중) - 계정(게스트/디스코드) - 오류(방 없음, 방 가득 참, 서버 업데이트, 오프라인) + +## 11. PWA (홈 화면 추가·오프라인 안내) +- 매니페스트 `/manifest.webmanifest`: 이름 "같이 놀자 보드게임", 짧은 이름 "같이 놀자", `lang: ko`, 시작 `/`, `display: standalone`, 테마·배경 `#F7F5F0`(`--bg`). 아이콘 `public/icons/`: 192·512(favicon 그대로, 둥근 모서리), 512 maskable(가득 채움, 판은 가운데 안전 영역 안), apple-touch-icon 180. 다시 만들기: `bun scripts/pwa-icons.ts`(Chrome으로 그림). +- 서비스 워커: 손으로 쓴 `apps/web/sw.js`를 빌드 때 Vite 플러그인이 빌드 ID(파일 이름 해시 기준)를 넣어 `dist/sw.js`로 낸다. 배포 빌드에서만 등록한다(개발 서버에서는 등록 안 함). + +| 요청 | 처리 | +|---|---| +| 페이지 이동(navigate) | 네트워크 먼저, 저장하지 않음. 네트워크 오류면 `/offline.html` | +| `/assets/*` (해시 파일) | 캐시 먼저(`bg-assets-v1`, 200개까지, 오래된 것부터 지움). 200 응답이고 HTML이 아닐 때만 저장(없는 파일은 서버가 index.html을 돌려주므로) | +| `/api/*`(관리자 API 포함), `/ws`, `/healthz`, `/internal/*`, 다른 출처, GET 아닌 요청 | 손대지 않음(저장 안 함) | + +- 캐시: `bg-shell-<빌드ID>`(오프라인 안내 페이지 하나)와 `bg-assets-v1`. 새 워커는 바로 활성화(`skipWaiting` + `clients.claim`)하고, 활성화할 때 이 둘이 아닌 `bg-*` 캐시를 지운다. index.html은 어디에도 저장하지 않으므로 온라인이면 새 배포가 바로 보인다. 해시 파일은 내용이 바뀌지 않아 배포 뒤에도 남겨 둔다. 워커의 저장 방식을 바꾸면 `bg-assets-v1`의 숫자를 올린다. +- 오프라인 안내 페이지: 다른 파일 없이 혼자 그려지는 HTML(인라인 스타일, 밝게/어둡게). 스크립트는 없다(CSP가 인라인 스크립트를 막음). [다시 시도]는 같은 주소를 다시 연다. 자동 새로고침(meta refresh)은 접근성 검사(axe) 위반이라 쓰지 않는다. +- 앱 안: 인터넷이 끊기면 `ConnectionBanner`가 위쪽 띠로 알린다(방 안에서는 기존 재연결 문구). +- 홈 화면에 추가: 홈 아래쪽 카드. `beforeinstallprompt`를 잡아 [추가하기] 버튼을 보이고, iOS(이 이벤트 없음)는 "공유 버튼 → [홈 화면에 추가]" 안내만. 이미 앱으로 열었거나(standalone) [닫기]를 누르면(기기에 기억) 안 보인다. +- 서버: `/sw.js`·`/manifest.webmanifest`·`/offline.html`은 index.html과 같이 `no-cache`, manifest는 `application/manifest+json`(Bun 기본). CSP `default-src 'self'`가 워커·매니페스트를 허용해 따로 넣은 것은 없다. +- 확인: `apps/web/src/sw.test.ts`(캐시 규칙), `bun e2e/pwa.e2e.ts`(매니페스트·아이콘, 워커 활성화, 오프라인 띠·안내 페이지, 다시 온라인, 설치 카드). diff --git a/docs/11-deployment-ops.md b/docs/11-deployment-ops.md index 4db9051..d76a434 100644 --- a/docs/11-deployment-ops.md +++ b/docs/11-deployment-ops.md @@ -25,6 +25,7 @@ deploy/ - `encode zstd gzip` - `/assets/*`: `Cache-Control: public, max-age=31536000, immutable` (파일명에 해시) - `index.html`: `no-cache` (배포 즉시 반영) +- `/sw.js`·`/manifest.webmanifest`·`/offline.html`: `no-cache`(서비스 워커 갱신). 워커는 페이지와 `/api`·`/ws`를 저장하지 않는다(07 문서 11절). - 보안 헤더: CSP(04 문서 7절), `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, HSTS. - WebSocket 프록시는 Caddy 기본 지원. 연결 유지 시간 제한 없음. diff --git a/e2e/a11y.e2e.ts b/e2e/a11y.e2e.ts index 3dd6a51..39a87db 100644 --- a/e2e/a11y.e2e.ts +++ b/e2e/a11y.e2e.ts @@ -8,7 +8,7 @@ import { join } from 'node:path'; import { loadConfig } from '../apps/server/src/config'; import { startServer } from '../apps/server/src/server'; -const PORT = 4520; +const PORT = Number(process.env.E2E_PORT ?? 4520); const ORIGIN = `http://localhost:${PORT}`; const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); const app = startServer({ diff --git a/e2e/pwa.e2e.ts b/e2e/pwa.e2e.ts new file mode 100644 index 0000000..d87e6c4 --- /dev/null +++ b/e2e/pwa.e2e.ts @@ -0,0 +1,187 @@ +/** + * E2E (docs/07 §11): manifest + icons, service worker install, offline page, offline banner, + * "홈 화면에 추가" card. Run: bun e2e/pwa.e2e.ts (needs apps/web/dist built) + */ +import { chromium, type Page } from 'playwright-core'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from '../apps/server/src/config'; +import { startServer } from '../apps/server/src/server'; + +const PORT = Number(process.env.E2E_PORT ?? 4521); +const ORIGIN = `http://localhost:${PORT}`; +const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results'); +const DIST = join(import.meta.dir, '../apps/web/dist'); +const AXE = readFileSync(require.resolve('axe-core/axe.min.js'), 'utf8'); +mkdirSync(SHOTS, { recursive: true }); +if (!existsSync(join(DIST, 'sw.js'))) { + console.error('apps/web/dist/sw.js가 없어요. 먼저 (cd apps/web && bunx vite build)'); + process.exit(1); +} + +const dbDir = mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')); +const app = startServer({ + config: loadConfig({ PORT: String(PORT), DB_PATH: join(dbDir, 'app.db'), PUBLIC_ORIGIN: ORIGIN }), + log: () => {}, + staticDir: DIST, +}); + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +let failed = false; +const step = async (name: string, fn: () => Promise) => { + const t0 = performance.now(); + try { + await fn(); + console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`); + } catch (e) { + failed = true; + console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`); + throw e; + } +}; +const assert = (ok: unknown, msg: string) => { + if (!ok) throw new Error(msg); +}; + +/** PNG width/height from the IHDR chunk. */ +function pngSize(buf: ArrayBuffer): string { + const v = new DataView(buf); + assert(v.getUint32(0) === 0x89504e47, 'not a PNG'); + return `${v.getUint32(16)}x${v.getUint32(20)}`; +} + +/** axe-core: no serious/critical violations (same bar as a11y.e2e.ts). */ +async function axe(p: Page, name: string) { + await p.addScriptTag({ content: AXE }); + const res = await p.evaluate(async () => (window as any).axe.run(document, { resultTypes: ['violations'] })); + const serious = res.violations.filter((v: any) => v.impact === 'serious' || v.impact === 'critical'); + assert(!serious.length, `${name} a11y: ${serious.map((v: any) => v.id).join(', ')}`); +} + +/** Every URL the app's caches hold (pages must never be among them). */ +const cachedUrls = (p: Page) => + p.evaluate(async () => { + const out: string[] = []; + for (const name of await caches.keys()) for (const r of await (await caches.open(name)).keys()) out.push(`${name} ${new URL(r.url).pathname}`); + return out; + }); + +try { + await step('manifest·아이콘·sw.js 응답', async () => { + const m = await fetch(`${ORIGIN}/manifest.webmanifest`); + assert(m.ok && m.headers.get('content-type')?.startsWith('application/manifest+json'), `manifest: ${m.status} ${m.headers.get('content-type')}`); + const man = (await m.json()) as { name: string; short_name: string; lang: string; start_url: string; display: string; theme_color: string; icons: { src: string; sizes: string; purpose?: string }[] }; + assert(man.name === '같이 놀자 보드게임' && man.short_name && man.lang === 'ko' && man.start_url === '/' && man.display === 'standalone', 'manifest fields'); + assert(/^#[0-9a-f]{6}$/i.test(man.theme_color), 'theme_color'); + const icons = [...man.icons, { src: '/icons/apple-touch-icon.png', sizes: '180x180' }]; + assert(man.icons.some((i) => i.sizes === '192x192') && man.icons.some((i) => i.sizes === '512x512' && i.purpose === 'maskable'), 'icon set'); + for (const i of icons) { + const r = await fetch(ORIGIN + i.src); + assert(r.ok && r.headers.get('content-type') === 'image/png', `${i.src}: ${r.status} ${r.headers.get('content-type')}`); + const size = pngSize(await r.arrayBuffer()); + assert(size === i.sizes, `${i.src}: ${size} != ${i.sizes}`); + } + const sw = await fetch(`${ORIGIN}/sw.js`); + assert(sw.ok && sw.headers.get('content-type')?.includes('javascript'), `sw.js type ${sw.headers.get('content-type')}`); + assert(!(await sw.text()).includes("'__BUILD__'"), 'sw.js build id not filled in'); + // Must revalidate every time, or a deploy would not reach installed workers (docs/11). + for (const [path, res] of [['/sw.js', sw], ['/manifest.webmanifest', m], ['/offline.html', await fetch(`${ORIGIN}/offline.html`)]] as const) { + assert(res.ok && res.headers.get('cache-control') === 'no-cache', `${path} cache-control ${res.status} ${res.headers.get('cache-control')}`); + } + }); + + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'ko-KR' }); + const page = await ctx.newPage(); + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + + await step('서비스 워커 등록·활성화 → 페이지 제어', async () => { + await page.goto(ORIGIN + '/'); + await page.getByText('게임 고르기').waitFor(); + assert(await page.locator('link[rel="manifest"]').count(), 'manifest link'); + const state = await page.evaluate(async () => (await navigator.serviceWorker.ready).active?.state); + assert(state === 'activated', `sw state ${state}`); + await page.waitForFunction(() => !!navigator.serviceWorker.controller); + await page.reload(); // now the page's own files go through the worker + await page.getByText('게임 고르기').waitFor(); + const urls = await cachedUrls(page); + assert(urls.some((u) => /^bg-shell-\w+ \/offline\.html$/.test(u)), `offline page not cached: ${urls.join(', ')}`); + assert(urls.some((u) => /^bg-assets-v1 \/assets\/index-.+\.js$/.test(u)), `assets not cached: ${urls.join(', ')}`); + const bad = urls.filter((u) => !/ \/(assets\/.+|offline\.html)$/.test(u)); + assert(!bad.length, `unexpected cache entries: ${bad.join(', ')}`); + }); + + await step('오프라인: 앱 안 띠 → 새로 열면 오프라인 안내', async () => { + await ctx.setOffline(true); + await page.getByRole('status').getByText('인터넷 연결이 없어요').waitFor(); + await page.screenshot({ path: join(SHOTS, 'pwa-1-offline-banner.png') }); + await page.goto(ORIGIN + '/r/123456'); + await page.getByRole('heading', { name: '인터넷 연결이 없어요' }).waitFor(); + assert(page.url() === `${ORIGIN}/r/123456`, `url ${page.url()}`); + await page.screenshot({ path: join(SHOTS, 'pwa-2-offline-page.png') }); + await axe(page, 'offline page'); + }); + + await step('다시 온라인: [다시 시도] → 앱이 열림', async () => { + await ctx.setOffline(false); + await page.getByRole('link', { name: '다시 시도' }).click(); + await page.waitForFunction(() => !!document.getElementById('root')?.childElementCount); // the app, not the offline page + assert(page.url() === `${ORIGIN}/r/123456`, `url ${page.url()}`); + await page.goto(ORIGIN + '/'); + await page.getByText('게임 고르기').waitFor(); + assert((await page.getByRole('status').filter({ hasText: '인터넷 연결이 없어요' }).count()) === 0, 'offline banner still shown'); + const bad = (await cachedUrls(page)).filter((u) => !/ \/(assets\/.+|offline\.html)$/.test(u)); + assert(!bad.length, `pages got cached: ${bad.join(', ')}`); + }); + + await step('홈 화면에 추가: 설치 가능한 브라우저', async () => { + await page.evaluate(() => { + const e = Object.assign(new Event('beforeinstallprompt', { cancelable: true }), { + prompt: async () => void ((window as { prompted?: boolean }).prompted = true), + userChoice: Promise.resolve({ outcome: 'accepted' }), + }); + window.dispatchEvent(e); + }); + const card = page.getByRole('region', { name: '홈 화면에 추가' }); + await card.waitFor(); + await card.scrollIntoViewIfNeeded(); + await page.screenshot({ path: join(SHOTS, 'pwa-3-install-card.png') }); + await axe(page, 'install card'); + await card.getByRole('button', { name: '추가하기' }).click(); + await card.waitFor({ state: 'detached' }); + assert(await page.evaluate(() => (window as { prompted?: boolean }).prompted), 'prompt() not called'); + }); + if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`); + await ctx.close(); + + await step('홈 화면에 추가: iPhone Safari 안내 → 닫으면 다시 안 보임', async () => { + const ios = await browser.newContext({ + viewport: { width: 390, height: 844 }, + isMobile: true, + hasTouch: true, + locale: 'ko-KR', + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1', + }); + const p = await ios.newPage(); + await p.goto(ORIGIN + '/'); + const card = p.getByRole('region', { name: '홈 화면에 추가' }); + await card.getByText('[홈 화면에 추가]').waitFor(); + assert((await card.getByRole('button', { name: '추가하기' }).count()) === 0, 'iOS has no install button'); + await card.scrollIntoViewIfNeeded(); + await p.screenshot({ path: join(SHOTS, 'pwa-4-install-ios.png') }); + await card.getByRole('button', { name: '닫기' }).click(); + await card.waitFor({ state: 'detached' }); + await p.reload(); + await p.getByText('게임 고르기').waitFor(); + assert((await card.count()) === 0, 'dismissed card came back'); + await ios.close(); + }); +} catch (e) { + failed = true; + console.error(e); +} finally { + await browser.close(); + await app.stop(); +} +console.log(failed ? 'E2E FAILED' : 'E2E PASSED'); +process.exit(failed ? 1 : 0); diff --git a/scripts/pwa-icons.ts b/scripts/pwa-icons.ts new file mode 100644 index 0000000..bf6f57a --- /dev/null +++ b/scripts/pwa-icons.ts @@ -0,0 +1,34 @@ +/** + * Renders the PWA icons (docs/07 §11) from the favicon design with headless Chrome. + * bun scripts/pwa-icons.ts → apps/web/public/icons/*.png + * "any" icons keep the favicon's rounded corners; maskable/apple icons are full-bleed + * (the OS applies its own mask) with the board inside the 40% safe circle. + */ +import { chromium } from 'playwright-core'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const PUBLIC = join(import.meta.dir, '../apps/web/public'); +const OUT = join(PUBLIC, 'icons'); +mkdirSync(OUT, { recursive: true }); + +const rounded = readFileSync(join(PUBLIC, 'favicon.svg'), 'utf8'); +// Same board as the favicon, shrunk to 80% so every stroke stays inside the safe circle. +const fullBleed = ``; + +const ICONS: [file: string, size: number, svg: string][] = [ + ['icon-192.png', 192, rounded], + ['icon-512.png', 512, rounded], + ['icon-maskable-512.png', 512, fullBleed], + ['apple-touch-icon.png', 180, fullBleed], +]; + +const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true }); +const page = await browser.newPage(); +for (const [file, size, svg] of ICONS) { + await page.setViewportSize({ width: size, height: size }); + await page.setContent(`${svg}`); + await page.screenshot({ path: join(OUT, file), omitBackground: true }); + console.log(`✓ ${file}`); +} +await browser.close();