- 웹(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>
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
/**
|
||
* 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();
|