M1 웹 + 배포: 홈·코드 입장·대기실·오목 화면·결과·계정, E2E, Docker/Caddy

- 웹(React+Vite): 홈(큰 버튼 2개·하던 게임 돌아가기), 6자리 코드 입력(붙여넣기 감지·자동 입장),
  링크 입장(닉네임 한 칸), 대기실(코드/링크 복사·공유·QR·자리·준비·방장 메뉴·설정),
  오목(SVG 판·두 번 눌러 확정·금수/위험 표시·시계·무르기·무승부·기권·키보드 조작·스크린리더 안내),
  결과(한 판 더 투표·방 전적·공정성 확인), 채팅·반응, 계정(닉네임 변경·글자 크기·테마·소리·진동)
- WS 클라이언트: 지수 백오프+지터 재연결, 화면 복귀/온라인 즉시 재연결, seq 순서 처리,
  ping/pong 지연·시계 보정, 서버 재시작 안내, 다른 창 접속 처리
- @bg/shared/lite: 브라우저용 진입점(zod 제외) → 첫 화면 JS 약 90KB gzip
- e2e/omok.e2e.ts: 브라우저 두 개(모바일·PC)로 방 만들기→링크 입장→대국→새로고침 복구→승리→한 판 더
- deploy: Dockerfile(운영 의존성만, 267MB), docker-compose(app+Caddy), Caddyfile(보안 헤더·CSP), .env.example
- 프록시 뒤에서만 X-Forwarded-For 신뢰(TRUST_PROXY=1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:37:33 +09:00
parent b1f3d7c500
commit 17b1d0fcda
46 changed files with 3590 additions and 90 deletions

72
apps/web/src/net/api.ts Normal file
View File

@@ -0,0 +1,72 @@
import type { CatalogEntry, PublicUser } from '@bg/shared/lite';
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
let res: Response;
try {
res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
} catch {
throw new ApiError('인터넷 연결을 확인해 주세요.', 0);
}
const data = (await res.json().catch(() => ({}))) as { error?: string } & T;
if (!res.ok) throw new ApiError(data.error ?? '잠시 후 다시 시도해 주세요.', res.status);
return data;
}
export interface Settings {
fontScale?: 'normal' | 'large' | 'xlarge';
theme?: 'auto' | 'light' | 'dark';
sound?: boolean;
vibrate?: boolean;
colorBlind?: boolean;
tapConfirm?: boolean;
}
export interface MeResponse {
me: PublicUser | null;
settings?: Settings;
useAvatar?: boolean;
hasDiscordAvatar?: boolean;
activeRoom?: string | null;
}
export interface RoomPreview {
code: string;
codeLabel: string;
gameId: string;
host: string;
seated: number;
maxPlayers: number;
status: string;
}
export const api = {
config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'),
me: () => call<MeResponse>('GET', '/api/me'),
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
logout: () => call<{ ok: true }>('POST', '/api/auth/logout', {}),
logoutAll: () => call<{ ok: true }>('POST', '/api/auth/logout-all', {}),
deleteMe: () => call<{ ok: true }>('DELETE', '/api/me', { confirm: '탈퇴' }),
stats: () => call<{ stats: { gameId: string; played: number; wins: number; draws: number }[] }>('GET', '/api/me/stats'),
createRoom: (gameId: string, visibility?: 'private' | 'public') => call<{ code: string }>('POST', '/api/rooms', { gameId, visibility }),
quickPlay: (gameId: string) => call<{ code: string }>('POST', `/api/quick/${gameId}`, {}),
room: (code: string) => call<RoomPreview>('GET', `/api/rooms/${code}`),
publicRooms: (gameId: string) =>
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
};
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;

344
apps/web/src/net/socket.ts Normal file
View File

@@ -0,0 +1,344 @@
/**
* WebSocket client with auto-reconnect, seq ordering, ping/clock sync (docs/03 §4–§8).
* Room/game state lives in the zustand store below; components only read it.
*/
import { create } from 'zustand';
import { PROTOCOL, type GameResultView, type PublicUser, type RoomView, type ServerMessage } from '@bg/shared/lite';
import { toast } from '../store/toast';
export type ConnStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'replaced' | 'offline';
export interface ChatLine {
id: number;
from: PublicUser | null;
text: string;
at: number;
}
export interface GameState {
seq: number;
view: unknown;
active: string[];
deadline: number | null;
events: unknown[];
}
interface RoomStore {
status: ConnStatus;
me: PublicUser | null;
code: string | null;
room: RoomView | null;
roomSeq: number;
game: GameState | null;
result: GameResultView | null;
chat: ChatLine[];
emotes: { id: number; from: string; emote: string }[];
pending: number | null;
slow: boolean;
rttMs: number | null;
clockOffset: number;
joinError: string | null;
kicked: boolean;
needsRefresh: boolean;
}
export const useRoom = create<RoomStore>(() => ({
status: 'idle',
me: null,
code: null,
room: null,
roomSeq: -1,
game: null,
result: null,
chat: [],
emotes: [],
pending: null,
slow: false,
rttMs: null,
clockOffset: 0,
joinError: null,
kicked: false,
needsRefresh: false,
}));
const BACKOFF = [500, 1000, 2000, 4000, 8000];
const MAX_BACKOFF = 10_000;
const PING_EVERY = 10_000;
const PONG_TIMEOUT = 5_000;
class GameSocket {
private ws: WebSocket | null = null;
private attempt = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private pongTimer: ReturnType<typeof setTimeout> | null = null;
private pendingTimer: ReturnType<typeof setTimeout> | null = null;
private offsets: number[] = [];
private wanted = false;
private cs = Date.now();
private chatId = 1;
private onMessageHooks = new Set<(m: ServerMessage) => void>();
constructor() {
if (typeof window !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') this.kick();
});
window.addEventListener('online', () => this.kick());
window.addEventListener('offline', () => useRoom.setState({ status: 'offline' }));
}
}
/** Start (or keep) a connection. Safe to call repeatedly. */
start(): void {
this.wanted = true;
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.open();
}
stop(): void {
this.wanted = false;
this.clearTimers();
this.ws?.close();
this.ws = null;
useRoom.setState({ status: 'idle' });
}
/** Reconnect immediately if we should be connected but are not. */
private kick(): void {
if (!this.wanted) return;
if (!this.ws || this.ws.readyState === WebSocket.CLOSED || this.ws.readyState === WebSocket.CLOSING) {
if (this.retryTimer) clearTimeout(this.retryTimer);
this.retryTimer = null;
this.open();
} else if (this.ws.readyState === WebSocket.OPEN) {
this.ping();
}
}
private open(): void {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${proto}//${location.host}/ws`);
this.ws = ws;
useRoom.setState({ status: this.attempt === 0 ? 'connecting' : 'reconnecting' });
ws.onopen = () => {
this.attempt = 0;
this.pingTimer = setInterval(() => this.ping(), PING_EVERY);
};
ws.onmessage = (e) => {
let m: ServerMessage;
try {
m = JSON.parse(String(e.data));
} catch {
return;
}
this.handle(m);
for (const h of this.onMessageHooks) h(m);
};
ws.onclose = (e) => {
if (this.ws !== ws) return;
this.clearTimers();
this.ws = null;
const st = useRoom.getState().status;
if (!this.wanted || st === 'replaced' || useRoom.getState().kicked) return;
if (e.code === 4401 || e.code === 1008) {
// Not logged in (session expired): stop and let the page ask for a nickname.
useRoom.setState({ status: 'idle' });
this.wanted = false;
window.dispatchEvent(new Event('bg:unauthorized'));
return;
}
if (this.attempt >= 1) {
// Repeated failures may mean the session is gone (the upgrade got a 401).
fetch('/api/me', { credentials: 'same-origin' })
.then((r) => r.json())
.then((d: { me: unknown }) => {
if (d.me === null) {
this.wanted = false;
useRoom.setState({ status: 'idle' });
window.dispatchEvent(new Event('bg:unauthorized'));
}
})
.catch(() => {});
}
this.scheduleReconnect();
};
}
private scheduleReconnect(extraDelay = 0): void {
const base = BACKOFF[this.attempt] ?? MAX_BACKOFF;
const jitter = base * (0.7 + Math.random() * 0.6);
this.attempt++;
useRoom.setState({ status: navigator.onLine === false ? 'offline' : 'reconnecting' });
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
if (this.wanted) this.open();
}, jitter + extraDelay);
}
private clearTimers(): void {
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pingTimer = null;
this.pongTimer = null;
}
private ping(): void {
if (this.ws?.readyState !== WebSocket.OPEN || this.pongTimer) return;
this.send({ t: 'ping', ts: Date.now() });
this.pongTimer = setTimeout(() => {
this.pongTimer = null;
// Dead connection: force a reconnect.
this.ws?.close();
}, PONG_TIMEOUT);
}
send(m: unknown): boolean {
if (this.ws?.readyState !== WebSocket.OPEN) return false;
this.ws.send(JSON.stringify(m));
return true;
}
subscribe(fn: (m: ServerMessage) => void): () => void {
this.onMessageHooks.add(fn);
return () => this.onMessageHooks.delete(fn);
}
// ------------------------------------------------------------- room commands
join(code: string, as: 'player' | 'spectator' = 'player'): void {
const cur = useRoom.getState();
if (cur.code !== code) {
useRoom.setState({ code, room: null, roomSeq: -1, game: null, result: null, chat: [], joinError: null, kicked: false, pending: null });
}
this.start();
this.send({ t: 'join', code, as });
}
leave(): void {
this.send({ t: 'leave' });
useRoom.setState({ code: null, room: null, roomSeq: -1, game: null, result: null, chat: [], pending: null });
}
/** Sends a game action; returns false when not connected. */
act(a: unknown): boolean {
const cs = ++this.cs;
if (!this.send({ t: 'act', cs, a })) {
toast('연결이 끊겨 있어요. 다시 연결되면 해 주세요.', 'error');
return false;
}
useRoom.setState({ pending: cs, slow: false });
if (this.pendingTimer) clearTimeout(this.pendingTimer);
this.pendingTimer = setTimeout(() => {
if (useRoom.getState().pending === cs) {
useRoom.setState({ slow: true });
this.ping();
}
}, 2000);
return true;
}
/** Server time estimate. */
now(): number {
return Date.now() + useRoom.getState().clockOffset;
}
// ------------------------------------------------------------- inbound
private handle(m: ServerMessage): void {
const st = useRoom.getState();
switch (m.t) {
case 'welcome': {
if (m.protocol !== PROTOCOL) useRoom.setState({ needsRefresh: true });
useRoom.setState({ status: 'open', me: m.me });
this.offsets = [m.serverTime - Date.now()];
useRoom.setState({ clockOffset: this.offsets[0]! });
// Re-join the room we were in (reconnect path).
if (st.code && !st.kicked) this.send({ t: 'join', code: st.code, as: 'player' });
break;
}
case 'room':
if (m.seq >= st.roomSeq || m.room.code !== st.room?.code) {
useRoom.setState({ room: m.room, roomSeq: m.seq, joinError: null });
if (m.room.status === 'playing' && st.result) useRoom.setState({ result: null });
}
break;
case 'state': {
if (st.game && m.seq < st.game.seq) break; // stale
const patch: Partial<RoomStore> = {
game: { seq: m.seq, view: m.view, active: m.active, deadline: m.deadline, events: m.events },
};
if (m.cs !== undefined && m.cs === st.pending) {
patch.pending = null;
patch.slow = false;
}
useRoom.setState(patch);
break;
}
case 'reject':
if (m.cs === st.pending) useRoom.setState({ pending: null, slow: false });
toast(m.reason, 'error');
break;
case 'result':
useRoom.setState({ result: m.result });
break;
case 'chat':
useRoom.setState({ chat: [...st.chat, { id: this.chatId++, from: m.from, text: m.text, at: m.at }].slice(-100) });
break;
case 'emote': {
const id = this.chatId++;
useRoom.setState({ emotes: [...st.emotes, { id, from: m.from, emote: m.id }].slice(-10) });
setTimeout(() => useRoom.setState((s) => ({ emotes: s.emotes.filter((e) => e.id !== id) })), 2500);
break;
}
case 'notice':
toast(m.message);
break;
case 'error':
if (m.code === 'room-not-found' || m.code === 'join-failed') useRoom.setState({ joinError: m.message });
else toast(m.message, 'error');
break;
case 'pong': {
if (this.pongTimer) clearTimeout(this.pongTimer);
this.pongTimer = null;
const now = Date.now();
const rtt = now - m.ts;
const offset = m.serverTime + rtt / 2 - now;
this.offsets = [...this.offsets, offset].slice(-5);
const sorted = [...this.offsets].sort((a, b) => a - b);
useRoom.setState({ rttMs: rtt, clockOffset: sorted[Math.floor(sorted.length / 2)]! });
if (st.slow && st.pending) {
// Connection is alive but our action got no answer: resync instead of resending.
this.send({ t: 'sync' });
useRoom.setState({ pending: null, slow: false });
}
break;
}
case 'bye':
if (m.reason === 'restart') {
toast('서버를 업데이트하고 있어요. 잠시 후 자동으로 다시 연결돼요.');
this.attempt = 0;
this.ws?.close();
// onclose schedules a reconnect; add 1–3s so clients don't all return at once.
this.ws = null;
this.clearTimers();
this.scheduleReconnect(1000 + Math.random() * 2000);
} else if (m.reason === 'replaced') {
useRoom.setState({ status: 'replaced' });
} else if (m.reason === 'kicked') {
useRoom.setState({ kicked: true, joinError: '방장이 방에서 내보냈어요.' });
}
break;
}
}
/** "Use here" after another tab took over. */
reclaim(): void {
useRoom.setState({ status: 'connecting' });
this.wanted = true;
this.attempt = 0;
this.open();
}
}
export const socket = new GameSocket();