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:
72
apps/web/src/net/api.ts
Normal file
72
apps/web/src/net/api.ts
Normal 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
344
apps/web/src/net/socket.ts
Normal 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();
|
||||
Reference in New Issue
Block a user