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:
EJClaw
2026-10-06 13:12:23 +09:00
parent c27cb0a11a
commit 2ed1a7ed33
21 changed files with 716 additions and 5 deletions

View File

@@ -9,6 +9,12 @@
<meta property="og:description" content="링크를 누르면 바로 같이 할 수 있어요. 오목, 바둑, 섯다, 포커, 윷놀이…" />
<meta property="og:type" content="website" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="같이 놀자" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<title>같이 놀자 보드게임</title>
</head>
<body>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -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" }
]
}

View File

@@ -0,0 +1,95 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#F7F5F0" />
<!-- Served by the service worker when a page can't load. No scripts (CSP blocks inline JS) and no other files. -->
<title>인터넷 연결이 없어요 · 같이 놀자 보드게임</title>
<style>
:root {
--bg: #f7f5f0;
--surface: #fff;
--text: #1c1e21;
--text-sub: #4a4f57;
--primary: #2563eb;
--primary-text: #fff;
--focus: #f59e0b;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #16181d;
--surface: #20232a;
--text: #f2f3f5;
--text-sub: #b8bcc4;
--primary: #5b8def;
--primary-text: #0b1020;
}
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font: 500 17px/1.5 -apple-system, BlinkMacSystemFont, 'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans KR', sans-serif;
}
main {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
background: var(--surface);
border-radius: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
padding: 32px 24px;
max-width: 420px;
text-align: center;
}
.icon {
font-size: 48px;
line-height: 1;
}
h1 {
font-size: 1.4rem;
font-weight: 700;
margin: 16px 0 8px;
}
p {
color: var(--text-sub);
margin: 0 0 24px;
}
a.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 56px;
padding: 0 28px;
border-radius: 12px;
background: var(--primary);
color: var(--primary-text);
font-size: 18px;
font-weight: 700;
text-decoration: none;
}
a.btn:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
</style>
</head>
<body>
<main>
<div class="card">
<div class="icon" aria-hidden="true">📡</div>
<h1>인터넷 연결이 없어요</h1>
<p>와이파이나 데이터가 켜져 있는지 확인한 뒤 [다시 시도]를 눌러 주세요.</p>
<a class="btn" href="">다시 시도</a>
</div>
</main>
</body>
</html>

View File

@@ -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 (
<section className="card row" aria-label="홈 화면에 추가" style={{ gap: 12 }}>
<span style={{ fontSize: '1.8rem' }} aria-hidden>
📲
</span>
<div style={{ flex: '1 1 220px' }}>
<strong>홈 화면에 추가해 보세요</strong>
<div className="muted small">
{mode === 'ios' ? 'Safari 공유 버튼(네모에 화살표)을 누르고 [홈 화면에 추가]를 골라 주세요.' : '앱처럼 아이콘을 눌러 바로 열 수 있어요.'}
</div>
</div>
{mode === 'prompt' && (
<button className="btn btn-primary btn-sm" onClick={() => void promptInstall()}>
추가하기
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={dismissInstall}>
닫기
</button>
</section>
);
}

View File

@@ -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 : <div className="conn-banner" role="status">인터넷 연결이 없어요. 연결되면 다시 쓸 수 있어요.</div>;
if (status === 'reconnecting' || status === 'connecting') return <div className="conn-banner">다시 연결하는 중…</div>;
if (status === 'offline') return <div className="conn-banner">인터넷 연결이 끊겼어요. 연결되면 자동으로 이어져요.</div>;
return null;

93
apps/web/src/lib/pwa.ts Normal file
View File

@@ -0,0 +1,93 @@
/** PWA glue (docs/07 §11): service worker registration, "홈 화면에 추가", online state. */
import { useSyncExternalStore } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
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<void> {
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);
}

View File

@@ -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(
<StrictMode>
<BrowserRouter>

View File

@@ -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() {
</aside>
</div>
<InstallCard />
{picker && (
<Sheet onClose={() => setPicker(false)} label="게임 고르기">
<h2>어떤 게임을 할까요?</h2>

137
apps/web/src/sw.test.ts Normal file
View File

@@ -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<string, Response>();
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<string, FakeCache>;
let net: (req: { url: string }) => Promise<Response>;
let fetched: string[];
let handlers: Record<string, (e: unknown) => 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<unknown>[] = [];
handlers[type]!({ waitUntil: (p: Promise<unknown>) => 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<Response> | undefined;
const pending: Promise<unknown>[] = [];
handlers.fetch!({
request: { url: new URL(path, opts.origin ?? ORIGIN).href, method: opts.method ?? 'GET', mode: opts.mode ?? 'cors' },
respondWith: (p: Promise<Response>) => (res = p),
waitUntil: (p: Promise<unknown>) => 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('<!doctype html>v1', { headers: { 'Content-Type': 'text/html' } });
expect(await (await request('/r/123456', { mode: 'navigate' }))!.text()).toBe('<!doctype html>v1');
net = async () => new Response('<!doctype html>v2', { headers: { 'Content-Type': 'text/html' } });
expect(await (await request('/', { mode: 'navigate' }))!.text()).toBe('<!doctype html>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('<!doctype html>', { 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');
});

68
apps/web/sw.js Normal file
View 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);
}

View File

@@ -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: {