From 2ed1a7ed335be267ed8bb35b7dfc137e1729bb2b Mon Sep 17 00:00:00 2001 From: EJClaw Date: Tue, 6 Oct 2026 13:12:23 +0900 Subject: [PATCH] =?UTF-8?q?M9:=20PWA=20=E2=80=94=20=ED=99=88=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=B6=94=EA=B0=80,=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 매니페스트·아이콘(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 --- README.md | 1 + apps/web/index.html | 6 + apps/web/public/icons/apple-touch-icon.png | Bin 0 -> 2876 bytes apps/web/public/icons/icon-192.png | Bin 0 -> 5106 bytes apps/web/public/icons/icon-512.png | Bin 0 -> 14347 bytes apps/web/public/icons/icon-maskable-512.png | Bin 0 -> 8207 bytes apps/web/public/manifest.webmanifest | 19 ++ apps/web/public/offline.html | 95 ++++++++++ apps/web/src/components/InstallCard.tsx | 28 +++ apps/web/src/components/ui.tsx | 4 +- apps/web/src/lib/pwa.ts | 93 ++++++++++ apps/web/src/main.tsx | 2 + apps/web/src/pages/Home.tsx | 3 + apps/web/src/sw.test.ts | 137 ++++++++++++++ apps/web/sw.js | 68 +++++++ apps/web/vite.config.ts | 22 ++- docs/07-ui-ux.md | 19 +- docs/11-deployment-ops.md | 1 + e2e/a11y.e2e.ts | 2 +- e2e/pwa.e2e.ts | 187 ++++++++++++++++++++ scripts/pwa-icons.ts | 34 ++++ 21 files changed, 716 insertions(+), 5 deletions(-) create mode 100644 apps/web/public/icons/apple-touch-icon.png create mode 100644 apps/web/public/icons/icon-192.png create mode 100644 apps/web/public/icons/icon-512.png create mode 100644 apps/web/public/icons/icon-maskable-512.png create mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/public/offline.html create mode 100644 apps/web/src/components/InstallCard.tsx create mode 100644 apps/web/src/lib/pwa.ts create mode 100644 apps/web/src/sw.test.ts create mode 100644 apps/web/sw.js create mode 100644 e2e/pwa.e2e.ts create mode 100644 scripts/pwa-icons.ts 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 0000000000000000000000000000000000000000..336f8ac27c4453584fb9c3fa32db854e28947d50 GIT binary patch literal 2876 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 RH(q-H4Ha$W8n|`X{{W36(iH#z literal 0 HcmV?d00001 diff --git a/apps/web/public/icons/icon-512.png b/apps/web/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..bad89dacb57633b6edfaf6a013ad89c7985ddd6a GIT binary patch literal 14347 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e930484878fa3018f610c5462367b8941965676a GIT binary patch literal 8207 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 b*6`#2(O@RK{rt@na0F0M)Ob>;U>5LiGv5%H literal 0 HcmV?d00001 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();