Files
joke-app/apps/web/src/net/socket.ts
EJClaw 17b1d0fcda 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>
2026-10-04 04:37:33 +09:00

345 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();