From 554c57834560d95cf167dcc33a00d39c9f74e89f Mon Sep 17 00:00:00 2001 From: kappa Date: Mon, 19 Jan 2026 10:17:27 +0900 Subject: [PATCH] Initial commit: Anvil Lounge chat application - React frontend with Vite + TypeScript - Cloudflare Worker backend with Durable Objects - AI-powered chat moderation via OpenAI - WebSocket-based real-time messaging - XSS prevention, rate limiting, input validation Co-Authored-By: Claude Opus 4.5 --- .gitignore | 26 + CLAUDE.md | 86 + README.md | 265 + docs/BUG_FIXES_2026-01.md | 225 + frontend/index.html | 13 + frontend/package.json | 26 + frontend/public/vite.svg | 1 + frontend/src/App.css | 106 + frontend/src/App.tsx | 71 + frontend/src/components/ChatRoom.css | 131 + frontend/src/components/ChatRoom.tsx | 179 + frontend/src/components/ErrorBoundary.tsx | 133 + frontend/src/components/MessageInput.css | 79 + frontend/src/components/MessageInput.tsx | 70 + frontend/src/components/MessageList.css | 147 + frontend/src/components/MessageList.tsx | 103 + frontend/src/config.ts | 70 + frontend/src/hooks/useWebSocket.ts | 217 + frontend/src/index.css | 19 + frontend/src/main.tsx | 10 + frontend/src/utils/sanitize.ts | 195 + frontend/src/vite-env.d.ts | 11 + frontend/tsconfig.json | 21 + frontend/tsconfig.node.json | 10 + frontend/vite.config.ts | 16 + package-lock.json | 5706 +++++++++++++++++++++ package.json | 17 + worker/package.json | 20 + worker/src/AIManager.ts | 943 ++++ worker/src/ChatRoom.ts | 844 +++ worker/src/Context7Client.ts | 328 ++ worker/src/RateLimiter.ts | 134 + worker/src/config.ts | 98 + worker/src/index.ts | 137 + worker/src/logger.ts | 84 + worker/src/validation.ts | 34 + worker/tsconfig.json | 15 + worker/wrangler.toml | 18 + 38 files changed, 10608 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/BUG_FIXES_2026-01.md create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/ChatRoom.css create mode 100644 frontend/src/components/ChatRoom.tsx create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/MessageInput.css create mode 100644 frontend/src/components/MessageInput.tsx create mode 100644 frontend/src/components/MessageList.css create mode 100644 frontend/src/components/MessageList.tsx create mode 100644 frontend/src/config.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/utils/sanitize.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 worker/package.json create mode 100644 worker/src/AIManager.ts create mode 100644 worker/src/ChatRoom.ts create mode 100644 worker/src/Context7Client.ts create mode 100644 worker/src/RateLimiter.ts create mode 100644 worker/src/config.ts create mode 100644 worker/src/index.ts create mode 100644 worker/src/logger.ts create mode 100644 worker/src/validation.ts create mode 100644 worker/tsconfig.json create mode 100644 worker/wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f13087 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +.wrangler/ + +# Environment files +.env +.env.local +.env.*.local +.dev.vars + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Logs +*.log +npm-debug.log* + +# TypeScript +*.tsbuildinfo diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f439e7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,86 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Real-time chat application with AI-powered moderation. Korean-language community chat ("Anvil Lounge") with an AI bot host (방장) that moderates conversations, answers questions, and provides weather/documentation lookups. + +## Architecture + +``` +chat-app/ +├── frontend/ # React SPA (Vite + TypeScript) +│ └── src/ +│ ├── components/ # ChatRoom, MessageList, MessageInput +│ ├── hooks/ # useWebSocket (WebSocket client with reconnection) +│ └── utils/ # sanitize.ts (XSS prevention, markdown→HTML) +└── worker/ # Cloudflare Worker + Durable Object + └── src/ + ├── index.ts # HTTP router, CORS handling + ├── ChatRoom.ts # Durable Object: WebSocket hub, message persistence + ├── AIManager.ts # OpenAI integration, moderation, tool calling + ├── Context7Client.ts # Documentation lookup API + ├── RateLimiter.ts # In-memory rate limiting + └── validation.ts # Zod schemas for message validation +``` + +### Key Design Patterns + +**Durable Objects with Hibernation API**: `ChatRoom` uses `webSocketMessage()`, `webSocketClose()`, `webSocketError()` handlers instead of event listeners. Session data stored via `ws.serializeAttachment()`. + +**AI Moderation Flow**: Messages are checked by AI for profanity before broadcast. Detected words are masked (첫 글자 + asterisks). Repeated violations trigger auto-silence via `trackViolation()` → DO storage transaction. + +**Storage Patterns**: All DO storage operations use transactions for atomicity and retry logic with jitter for resilience. + +## Development Commands + +```bash +# Root-level (npm workspaces) +npm run dev:worker # Start worker dev server (wrangler) +npm run dev:frontend # Start Vite dev server (localhost:5173) +npm run build:worker # Build worker +npm run build:frontend # Build frontend (tsc + vite) +npm run deploy:worker # Deploy to Cloudflare Workers +npm run deploy:frontend # Deploy to Cloudflare Pages + +# Type checking +cd worker && npx tsc --noEmit +cd frontend && npx tsc --noEmit + +# Worker secrets +wrangler secret put OPENAI_API_KEY +``` + +## API Endpoints + +| Endpoint | Description | +|----------|-------------| +| `GET /api/rooms/:roomId/websocket` | WebSocket upgrade for chat | +| `GET /api/rooms/:roomId/users` | List users in room | +| `GET /api/health` | Health check | + +## WebSocket Message Types + +**Client → Server**: `{ type: "message", content: string }` or `{ type: "rename", name: string }` + +**Server → Client**: `message`, `join`, `leave`, `userList`, `history`, `error` + +## Configuration Alignment + +Frontend `config.ts` and backend `validation.ts` must stay in sync: +- `maxMessageLength`: 2000 characters +- `maxUserNameLength`: 50 characters + +## Security Considerations + +- XSS prevention: `sanitize.ts` uses DOMPurify + custom URL validation (`isUrlSafe()`) to block encoded `javascript:` attacks +- API key redaction: `sanitizeErrorForLogging()` in AIManager.ts strips credentials from error logs +- CORS: Strict origin allowlist in `index.ts` (includes Pages preview deployments) +- Input validation: All WebSocket messages validated with Zod schemas before processing + +## External Services + +- **OpenAI API**: Chat completions with function calling (weather, docs, hosting info, silence user) +- **Open-Meteo API**: Weather data (no API key required) +- **Context7 API**: Documentation lookup for technical questions diff --git a/README.md b/README.md new file mode 100644 index 0000000..783b560 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# Chat App + +Cloudflare Workers + Durable Objects + React 기반 실시간 멀티유저 채팅 애플리케이션 + +## 아키텍처 + +``` +┌─────────────────┐ WebSocket ┌──────────────────┐ ┌─────────────────┐ +│ React │ ←───────────────→ │ Cloudflare │ ←─→ │ Durable Object │ +│ Frontend │ │ Worker │ │ (ChatRoom) │ +│ (Pages) │ │ (라우팅) │ │ (상태관리) │ +└─────────────────┘ └──────────────────┘ └─────────────────┘ + ↓ + ┌──────────────────┐ + │ OpenAI API │ + │ (AI 방장) │ + └──────────────────┘ +``` + +## 기술 스택 + +| 분류 | 기술 | +|------|------| +| 백엔드 | Cloudflare Workers, Durable Objects | +| 실시간 통신 | WebSocket Hibernation API | +| AI | OpenAI GPT-4o-mini, Function Calling | +| 프론트엔드 | React + Vite + TypeScript | +| 검증 | Zod (입력 검증) | +| 보안 | DOMPurify (XSS 방지), Rate Limiting | +| 배포 | Cloudflare Workers + Pages | + +## 프로젝트 구조 + +``` +chat-app/ +├── worker/ # Cloudflare Worker 백엔드 +│ ├── src/ +│ │ ├── index.ts # Worker 엔트리포인트, 라우팅, CORS +│ │ ├── ChatRoom.ts # Durable Object 채팅방 클래스 +│ │ ├── AIManager.ts # AI 방장 (OpenAI 연동) +│ │ ├── Context7Client.ts # 기술 문서 검색 클라이언트 +│ │ ├── validation.ts # Zod 입력 검증 스키마 +│ │ └── RateLimiter.ts # 슬라이딩 윈도우 Rate Limiter +│ ├── wrangler.toml # Worker 설정 +│ └── package.json +├── frontend/ # React 프론트엔드 +│ ├── src/ +│ │ ├── App.tsx +│ │ ├── components/ +│ │ │ ├── ChatRoom.tsx # 채팅방 UI +│ │ │ ├── MessageList.tsx # 메시지 목록 +│ │ │ └── MessageInput.tsx# 입력창 +│ │ ├── hooks/ +│ │ │ └── useWebSocket.ts # WebSocket 연결 훅 +│ │ └── utils/ +│ │ └── sanitize.ts # XSS 방지 유틸리티 +│ ├── vite.config.ts +│ └── package.json +└── package.json # 모노레포 루트 +``` + +## 기능 + +### 채팅 기능 +- 실시간 메시지 송수신 +- 사용자 입장/퇴장 알림 +- 접속자 목록 표시 +- 사용자 이름 변경 + +### AI 방장 (@방장) +- IT 기술 관련 질문 응답 +- 자동 환영 메시지 +- 채팅 중재 (부적절한 언어 경고) +- 명령어 처리 (/help, /rules, /pricing 등) +- Context7 API 연동 기술 문서 검색 + +### 보안 기능 +- CORS 제한 (특정 도메인만 허용) +- 입력값 검증 (Zod 스키마) +- XSS 방지 (DOMPurify + 안전한 마크다운) +- Rate Limiting (사용자별 요청 제한) +- API 타임아웃 (15초) +- 보안 헤더 (CSP, X-Frame-Options 등) + +## API 엔드포인트 + +### WebSocket +``` +GET /api/rooms/:roomId/websocket?name=사용자이름 +``` +WebSocket 연결을 위한 엔드포인트 + +### REST +``` +GET /api/rooms/:roomId/users # 채팅방 접속자 목록 +GET /api/health # 헬스체크 +``` + +## WebSocket 메시지 형식 + +### 클라이언트 → 서버 + +```typescript +// 메시지 전송 +{ type: 'message', content: '안녕하세요' } + +// 이름 변경 +{ type: 'rename', name: '새이름' } +``` + +### 서버 → 클라이언트 + +```typescript +// 일반 메시지 +{ type: 'message', id: 'uuid', name: '사용자', content: '내용', timestamp: 1234567890, isBot?: boolean } + +// 입장 +{ type: 'join', id: 'uuid', name: '사용자', timestamp: 1234567890 } + +// 퇴장 +{ type: 'leave', id: 'uuid', name: '사용자', timestamp: 1234567890 } + +// 사용자 목록 +{ type: 'userList', users: ['사용자1', '사용자2'], timestamp: 1234567890 } + +// 에러 +{ type: 'error', message: '에러 메시지', timestamp: 1234567890 } +``` + +## 로컬 개발 + +### 요구사항 +- Node.js 18+ +- pnpm + +### 설치 및 실행 + +```bash +# 의존성 설치 +pnpm install + +# Worker 로컬 실행 +cd worker +pnpm dev + +# Frontend 로컬 실행 (새 터미널) +cd frontend +pnpm dev +``` + +### 환경변수 + +Worker: +- `OPENAI_API_KEY`: OpenAI API 키 (wrangler.toml 또는 secrets) + +## 배포 + +### Worker 배포 +```bash +cd worker +pnpm deploy +``` + +### Frontend 배포 +```bash +cd frontend +pnpm build +pnpm pages:deploy +``` + +## 배포 URL + +| 서비스 | URL | +|--------|-----| +| Worker API | https://chat-worker.kappa-d8e.workers.dev | +| Frontend | https://chat-frontend-4wf.pages.dev | +| Custom Domain | https://chat.anvil.it.com | + +## 보안 구현 상세 + +### CORS 설정 +```typescript +const ALLOWED_ORIGINS = [ + 'https://chat.anvil.it.com', + 'https://chat-frontend-4wf.pages.dev', + 'http://localhost:5173', + 'http://localhost:3000', +]; +``` +- 정확한 도메인 매칭만 허용 +- Pages 프리뷰 도메인 패턴 제한 (`[commit].chat-frontend-4wf.pages.dev`) + +### 입력 검증 +```typescript +// 메시지: 1-2000자 +const MessageSchema = z.object({ + type: z.literal('message'), + content: z.string().min(1).max(2000).trim(), +}); + +// Room ID: 영문숫자, 하이픈, 언더스코어만 +const RoomIdSchema = z.string() + .min(1).max(100) + .regex(/^[a-zA-Z0-9_-]+$/); + +// 사용자 이름: 1-50자 +const UserNameSchema = z.string().min(1).max(50).trim(); +``` + +### Rate Limiting +| 대상 | 제한 | 차단 시간 | +|------|------|-----------| +| AI 요청 | 15회/분 | 2분 | +| 중재 위반 | 3회/5분 | 30분 | + +### 보안 헤더 +``` +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +X-XSS-Protection: 1; mode=block +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: default-src 'self'; connect-src 'self' wss: https:; ... +``` + +## AI 방장 명령어 + +| 명령어 | 설명 | +|--------|------| +| /help | 도움말 보기 | +| /rules | 채팅방 규칙 | +| /users | 현재 접속자 목록 | +| /pricing | Anvil Hosting 요금 안내 | +| /specs | 서버 스펙 안내 | +| /regions | 리전 정보 | +| /contact | 텔레그램 상담 연결 | +| @방장 [질문] | AI에게 질문하기 | + +## AI Function Calling + +AI 방장은 다음 기능을 호출할 수 있습니다: + +| 함수 | 설명 | +|------|------| +| `searchDocumentation` | 기술 문서 검색 (Context7 API) | +| `getAnvilHostingInfo` | Anvil Hosting 정보 조회 (pricing, specs, regions, features, contact) | +| `getCurrentDateTime` | 현재 날짜/시간 조회 (한국 기준) | +| `getWeather` | 도시별 날씨 조회 (Open-Meteo API) | + +### 텔레그램 봇 연동 +Anvil Hosting 문의 시 자동으로 텔레그램 봇 연결 안내: +- 봇: [@AnvilForgeBot](https://t.me/AnvilForgeBot) + +### 날씨 지원 도시 +서울, 부산, 도쿄, 오사카, 뉴욕, 런던 + +### 사용 예시 +``` +@방장 오늘 날짜가 뭐야? +@방장 서울 날씨 어때? +@방장 도쿄 날씨 알려줘 +``` + +## 라이선스 + +Private - Anvil Hosting diff --git a/docs/BUG_FIXES_2026-01.md b/docs/BUG_FIXES_2026-01.md new file mode 100644 index 0000000..df44789 --- /dev/null +++ b/docs/BUG_FIXES_2026-01.md @@ -0,0 +1,225 @@ +# Chat App 버그 수정 보고서 + +**수정일**: 2026-01-19 +**검토 범위**: frontend/, worker/ + +--- + +## 요약 + +| 우선순위 | 발견 | 수정 | +|---------|------|------| +| Critical | 4 | 4 | +| High | 4 | 4 | +| Medium | 9 | 9 | +| **합계** | **17** | **17** | + +--- + +## Critical Issues + +### 1. WebSocket Race Condition +**파일**: `frontend/src/hooks/useWebSocket.ts` +**증상**: 빠른 연속 호출 시 중복 WebSocket 연결 생성 +**원인**: 연결 상태 확인 전에 lock 획득이 이루어지지 않음 +**수정**: +```typescript +const connect = useCallback(() => { + // CRITICAL: Atomic lock acquisition to prevent race conditions + if (isConnectingRef.current) { + return; + } + // Acquire lock FIRST, before any other checks + isConnectingRef.current = true; + // ... +``` + +### 2. RateLimiter Memory Leak +**파일**: `worker/src/RateLimiter.ts` +**증상**: 장시간 운영 시 메모리 사용량 증가 +**원인**: `cleanup()` 메서드가 만료된 엔트리를 제대로 삭제하지 않음 +**수정**: +- blocked 상태 만료 시 즉시 삭제 +- 빈 timestamp 배열 엔트리 삭제 +- timestamp 배열 정리 로직 개선 + +### 3. API Key Exposure in Logs +**파일**: `worker/src/AIManager.ts` +**증상**: 에러 로그에 API 키가 노출될 가능성 +**원인**: `sanitizeErrorForLogging()` 함수의 패턴 누락 +**수정**: 다양한 API 키 형식 지원 +- OpenAI: `sk-proj-*`, `sk-svcacct-*` +- Anthropic: `sk-ant-api03-*` +- AWS: `AKIA*`, `ASIA*` +- Cloudflare tokens +- Basic auth credentials + +### 4. Silent Storage Failure +**파일**: `worker/src/ChatRoom.ts` +**증상**: DO storage 실패 시 메시지 유실 +**원인**: `saveMessage()`에 retry 로직 부재 +**수정**: +```typescript +const STORAGE_MAX_RETRIES = 3; +const STORAGE_RETRY_BASE_DELAY_MS = 50; + +private async saveMessage(message: StoredMessage): Promise { + for (let attempt = 1; attempt <= STORAGE_MAX_RETRIES; attempt++) { + try { + // storage operation + return; + } catch (error) { + if (attempt < STORAGE_MAX_RETRIES) { + const jitter = Math.random() * STORAGE_RETRY_BASE_DELAY_MS; + await delay(STORAGE_RETRY_BASE_DELAY_MS * attempt + jitter); + } + } + } +} +``` + +--- + +## High Priority Issues + +### 5. XSS via URL Encoding +**파일**: `frontend/src/utils/sanitize.ts` +**증상**: `javascript:` 같은 인코딩된 XSS 공격 가능 +**원인**: URL 검증 시 인코딩된 문자열 미처리 +**수정**: `isUrlSafe()` 함수 추가 +```typescript +function isUrlSafe(url: string): boolean { + const decoded = decodeURIComponent(url); + const doubleDecoded = decodeURIComponent(decoded); + // Check all versions for dangerous protocols + const dangerousProtocols = [ + /^javascript:/, /^vbscript:/, /^data:/, + /^file:/, /^about:/, /^blob:/, + ]; + // ... +} +``` + +### 6. TOCTOU Race Condition (Silence/Violations) +**파일**: `worker/src/ChatRoom.ts` +**증상**: 동시 요청 시 silence 체크 우회 가능 +**원인**: read-check-write가 atomic하지 않음 +**수정**: DO transaction 사용 +```typescript +private async isUserSilenced(userName: string): Promise { + return await this.ctx.storage.transaction(async (txn) => { + const entry = await txn.get(storageKey); + if (!entry) return false; + if (Date.now() >= entry.expiresAt) { + await txn.delete(storageKey); + return false; + } + return true; + }); +} +``` + +### 7. Unvalidated JSON Parsing +**파일**: `worker/src/AIManager.ts` +**증상**: OpenAI API 응답 형식 변경 시 런타임 에러 +**원인**: 응답 JSON 검증 부재 +**수정**: Zod schema 추가 +```typescript +const OpenAIResponseSchema = z.object({ + choices: z.array(z.object({ + message: z.object({ + content: z.string().nullable(), + tool_calls: z.array(OpenAIToolCallSchema).optional(), + }), + finish_reason: z.string(), + })), +}); +``` + +### 8. Missing API Timeout +**파일**: `worker/src/Context7Client.ts` +**증상**: 외부 API 무응답 시 무한 대기 +**원인**: fetch 요청에 timeout 미설정 +**수정**: `fetchWithTimeout()` 함수 추가 (10초 timeout) + +--- + +## Medium Priority Issues + +### 9. Frontend Message Array Memory Leak +**파일**: `frontend/src/components/ChatRoom.tsx`, `frontend/src/config.ts` +**증상**: 장시간 채팅 시 브라우저 메모리 증가 +**원인**: 메시지 배열 무한 증가 +**수정**: +```typescript +// config.ts +export const CHAT_CONFIG = { + maxDisplayMessages: 200, +} as const; + +// ChatRoom.tsx +function limitMessages(messages: DisplayMessage[]): DisplayMessage[] { + if (messages.length > CHAT_CONFIG.maxDisplayMessages) { + return messages.slice(-CHAT_CONFIG.maxDisplayMessages); + } + return messages; +} +``` + +### 10. Config Mismatch +**파일**: `frontend/src/config.ts` +**증상**: 프론트엔드에서 2000자 메시지 전송 시 잘림 +**원인**: 프론트엔드(1000) vs 백엔드(2000) 제한 불일치 +**수정**: `maxMessageLength: 2000`으로 통일 + +### 11. Context7 Cache Unbounded Growth +**파일**: `worker/src/Context7Client.ts` +**증상**: 캐시 크기 무한 증가 가능 +**원인**: 캐시 cleanup 로직 부재 +**수정**: +- `MAX_CACHE_SIZE = 100` 제한 +- `cleanupExpired()`: 만료 엔트리 정리 +- `evictOldest()`: LRU 방식 eviction +- `cleanupIfNeeded()`: 1분 간격 자동 정리 + +### 12. Serialization Error Handling +**파일**: `worker/src/ChatRoom.ts` +**증상**: WebSocket attachment 직렬화 실패 시 silent failure +**원인**: `serializeAttachment()` 에러 처리 부재 +**수정**: +```typescript +private setSession(ws: WebSocket, session: Session): boolean { + try { + ws.serializeAttachment(session); + return true; + } catch (error) { + console.error('Failed to serialize session attachment:', error); + return false; + } +} +``` + +--- + +## 수정된 파일 목록 + +| 파일 | 수정 내용 | +|------|----------| +| `frontend/src/hooks/useWebSocket.ts` | Race condition 수정 | +| `frontend/src/utils/sanitize.ts` | XSS URL 검증 강화 | +| `frontend/src/components/ChatRoom.tsx` | 메시지 배열 제한 | +| `frontend/src/config.ts` | 설정값 통일 및 추가 | +| `worker/src/ChatRoom.ts` | Storage retry, TOCTOU 수정, serialization 에러 처리 | +| `worker/src/AIManager.ts` | API key redaction, Zod validation | +| `worker/src/RateLimiter.ts` | Memory leak 수정 | +| `worker/src/Context7Client.ts` | Timeout 추가, cache cleanup | + +--- + +## 테스트 권장사항 + +1. **WebSocket 연결 테스트**: 빠른 연속 연결/해제 시 중복 연결 없음 확인 +2. **XSS 테스트**: `javascript:alert(1)` 같은 인코딩된 URL 차단 확인 +3. **메모리 테스트**: 장시간 채팅 후 브라우저 메모리 사용량 확인 +4. **Storage 테스트**: DO storage 일시적 실패 시 retry 동작 확인 +5. **동시성 테스트**: 동일 사용자 동시 요청 시 silence 체크 정상 동작 확인 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..143f1ec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Chat App + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0f2b4a9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,26 @@ +{ + "name": "chat-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "deploy": "wrangler pages deploy dist" + }, + "dependencies": { + "dompurify": "^3.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10", + "wrangler": "^3.91.0" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..6a41099 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..ed31346 --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,106 @@ +.join-container { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.join-card { + background: #16213e; + border-radius: 12px; + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.join-card h1 { + text-align: center; + margin-bottom: 8px; + color: #e94560; +} + +.join-card > p { + text-align: center; + color: #888; + margin-bottom: 30px; +} + +.nickname-display { + margin-bottom: 24px; +} + +.nickname-label { + display: block; + text-align: center; + font-size: 14px; + color: #888; + margin-bottom: 12px; +} + +.nickname-box { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + background: linear-gradient(135deg, #1a1a2e 0%, #0f3460 100%); + border: 2px solid #e94560; + border-radius: 12px; + padding: 16px 20px; +} + +.nickname { + font-size: 20px; + font-weight: 700; + color: #fff; + text-shadow: 0 0 10px rgba(233, 69, 96, 0.5); +} + +.reroll-button { + background: rgba(233, 69, 96, 0.2); + border: none; + border-radius: 8px; + padding: 8px 12px; + font-size: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.reroll-button:hover { + background: rgba(233, 69, 96, 0.4); + transform: rotate(180deg); +} + +.reroll-button:active { + transform: rotate(180deg) scale(0.9); +} + +.join-button { + width: 100%; + padding: 14px; + border: none; + border-radius: 8px; + background: #e94560; + color: white; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, transform 0.1s; +} + +.join-button:hover { + background: #d63a52; +} + +.join-button:active { + transform: scale(0.98); +} + +.powered-by { + text-align: center; + font-size: 12px; + color: #555; + margin-top: 20px; + margin-bottom: 0; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..2b38670 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import ChatRoom from './components/ChatRoom'; +import ErrorBoundary from './components/ErrorBoundary'; +import { ROOM_CONFIG, NICKNAME_ADJECTIVES, NICKNAME_NOUNS } from './config'; +import './App.css'; + +function generateRandomName(): string { + const adj = NICKNAME_ADJECTIVES[Math.floor(Math.random() * NICKNAME_ADJECTIVES.length)]; + const noun = NICKNAME_NOUNS[Math.floor(Math.random() * NICKNAME_NOUNS.length)]; + const num = Math.floor(Math.random() * 100); + return `${adj}${noun}${num}`; +} + +function App() { + const [userName, setUserName] = useState(''); + const [isJoined, setIsJoined] = useState(false); + + useEffect(() => { + setUserName(generateRandomName()); + }, []); + + const handleJoin = () => { + if (userName.trim()) { + setIsJoined(true); + } + }; + + const handleReroll = () => { + setUserName(generateRandomName()); + }; + + const handleLeave = () => { + setIsJoined(false); + setUserName(generateRandomName()); + }; + + if (isJoined) { + return ( + + + + ); + } + + return ( +
+
+

🔨 Anvil Lounge

+

Anvil Hosting 커뮤니티 채팅방

+ +
+ 당신의 닉네임 +
+ {userName} + +
+
+ + + +

Powered by Anvil Hosting

+
+
+ ); +} + +export default App; diff --git a/frontend/src/components/ChatRoom.css b/frontend/src/components/ChatRoom.css new file mode 100644 index 0000000..ef8f5da --- /dev/null +++ b/frontend/src/components/ChatRoom.css @@ -0,0 +1,131 @@ +.chat-room { + display: flex; + flex-direction: column; + height: 100vh; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + background: #16213e; + border-bottom: 1px solid #0f3460; +} + +.header-left { + flex: 1; +} + +.header-center { + display: flex; + align-items: center; + gap: 12px; +} + +.header-center h2 { + margin: 0; + font-size: 18px; + color: #e94560; +} + +.header-right { + flex: 1; + text-align: right; +} + +.leave-button { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: transparent; + color: #888; + font-size: 14px; + cursor: pointer; + transition: color 0.2s; +} + +.leave-button:hover { + color: #e94560; +} + +.connection-status { + font-size: 12px; + padding: 4px 10px; + border-radius: 12px; + background: #0f3460; +} + +.connection-status.connected { + background: #1e5128; + color: #4ade80; +} + +.connection-status.connecting { + background: #713f12; + color: #fbbf24; +} + +.connection-status.disconnected, +.connection-status.error { + background: #7f1d1d; + color: #f87171; +} + +.user-count { + font-size: 14px; + color: #888; +} + +.chat-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.user-list { + width: 200px; + padding: 16px; + background: #16213e; + border-right: 1px solid #0f3460; + overflow-y: auto; +} + +.user-list h3 { + font-size: 14px; + color: #888; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.user-list ul { + list-style: none; +} + +.user-list li { + padding: 8px 12px; + border-radius: 6px; + font-size: 14px; + color: #ccc; + margin-bottom: 4px; +} + +.user-list li.me { + background: #0f3460; + color: #e94560; + font-weight: 500; +} + +.messages-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +@media (max-width: 768px) { + .user-list { + display: none; + } +} diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx new file mode 100644 index 0000000..a906d94 --- /dev/null +++ b/frontend/src/components/ChatRoom.tsx @@ -0,0 +1,179 @@ +import { useState, useCallback } from 'react'; +import { useWebSocket, ChatMessage, HistoryMessage } from '../hooks/useWebSocket'; +import MessageList from './MessageList'; +import MessageInput from './MessageInput'; +import { CHAT_CONFIG } from '../config'; +import './ChatRoom.css'; + +/** + * Helper to limit message array size and prevent memory leaks + * Keeps the most recent messages up to maxDisplayMessages + */ +function limitMessages(messages: DisplayMessage[]): DisplayMessage[] { + if (messages.length > CHAT_CONFIG.maxDisplayMessages) { + return messages.slice(-CHAT_CONFIG.maxDisplayMessages); + } + return messages; +} + +// Generate unique message ID to prevent React key collisions +function generateMessageId(timestamp: number, suffix?: string): string { + const random = crypto.randomUUID().slice(0, 8); + return `${timestamp}-${suffix || 'msg'}-${random}`; +} + +interface ChatRoomProps { + roomId: string; + userName: string; + onLeave: () => void; +} + +interface DisplayMessage { + id: string; + type: 'message' | 'system' | 'error'; + name?: string; + content: string; + timestamp: number; + isMine?: boolean; + isBot?: boolean; +} + +function ChatRoom({ roomId, userName, onLeave }: ChatRoomProps) { + const [messages, setMessages] = useState([]); + const [users, setUsers] = useState([]); + + const handleMessage = useCallback( + (message: ChatMessage) => { + switch (message.type) { + case 'message': + setMessages((prev) => limitMessages([ + ...prev, + { + id: generateMessageId(message.timestamp, message.id), + type: 'message', + name: message.name, + content: message.content || '', + timestamp: message.timestamp, + isMine: message.name === userName, + isBot: message.isBot, + }, + ])); + break; + case 'join': + setMessages((prev) => limitMessages([ + ...prev, + { + id: generateMessageId(message.timestamp, 'join'), + type: 'system', + content: `${message.name}님이 입장했습니다.`, + timestamp: message.timestamp, + }, + ])); + break; + case 'leave': + setMessages((prev) => limitMessages([ + ...prev, + { + id: generateMessageId(message.timestamp, 'leave'), + type: 'system', + content: `${message.name}님이 퇴장했습니다.`, + timestamp: message.timestamp, + }, + ])); + break; + case 'userList': + if (message.users) { + setUsers(message.users); + } + break; + case 'history': + // Load message history when first connecting + if (message.messages && message.messages.length > 0) { + const historyMessages: DisplayMessage[] = message.messages.map((msg: HistoryMessage) => ({ + id: generateMessageId(msg.timestamp, msg.id), + type: 'message' as const, + name: msg.name, + content: msg.content, + timestamp: msg.timestamp, + isMine: msg.name === userName, + isBot: msg.isBot, + })); + setMessages((prev) => limitMessages([...historyMessages, ...prev])); + } + break; + case 'error': + // Display error messages (e.g., silence notifications, rate limits) + setMessages((prev) => limitMessages([ + ...prev, + { + id: generateMessageId(message.timestamp, 'error'), + type: 'error', + content: message.message || '오류가 발생했습니다.', + timestamp: message.timestamp, + }, + ])); + break; + } + }, + [userName] + ); + + const { sendMessage, isConnected, connectionStatus } = useWebSocket({ + roomId, + userName, + onMessage: handleMessage, + }); + + const handleSendMessage = (content: string) => { + if (content.trim()) { + sendMessage(content); + } + }; + + return ( +
+
+
+ +
+
+

🔨 Anvil Lounge

+ + {connectionStatus === 'connected' ? '연결됨' : '연결 중...'} + +
+
+ {users.length}명 참여중 +
+
+ +
+ + +
+ + +
+
+
+ ); +} + +export default ChatRoom; diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..a3669a0 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,133 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + // Log error to console in development + if (import.meta.env.DEV) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + } + + handleRetry = (): void => { + this.setState({ hasError: false, error: undefined }); + }; + + render(): ReactNode { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+

+ ⚠️ 문제가 발생했습니다 +

+

+ 예기치 않은 오류가 발생했습니다. +
+ 페이지를 새로고침하거나 다시 시도해주세요. +

+
+ + +
+ {import.meta.env.DEV && this.state.error && ( +
+ + 개발자 정보 + +
+                {this.state.error.message}
+                {'\n\n'}
+                {this.state.error.stack}
+              
+
+ )} +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend/src/components/MessageInput.css b/frontend/src/components/MessageInput.css new file mode 100644 index 0000000..fff8887 --- /dev/null +++ b/frontend/src/components/MessageInput.css @@ -0,0 +1,79 @@ +.message-input { + display: flex; + gap: 12px; + padding: 16px; + background: #16213e; + border-top: 1px solid #0f3460; +} + +.message-input .input-wrapper { + flex: 1; + position: relative; + display: flex; + flex-direction: column; +} + +.message-input .char-count { + position: absolute; + right: 12px; + bottom: -18px; + font-size: 11px; + color: #666; + transition: color 0.2s; +} + +.message-input .char-count.warning { + color: #e94560; + font-weight: 600; +} + +.message-input textarea { + flex: 1; + padding: 12px 16px; + border: 2px solid #0f3460; + border-radius: 24px; + background: #1a1a2e; + color: #eee; + font-size: 15px; + font-family: inherit; + resize: none; + min-height: 44px; + max-height: 120px; + transition: border-color 0.2s; +} + +.message-input textarea:focus { + outline: none; + border-color: #e94560; +} + +.message-input textarea::placeholder { + color: #555; +} + +.message-input textarea:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.message-input button { + padding: 12px 24px; + border: none; + border-radius: 24px; + background: #e94560; + color: white; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, opacity 0.2s; + white-space: nowrap; +} + +.message-input button:hover:not(:disabled) { + background: #d63a52; +} + +.message-input button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx new file mode 100644 index 0000000..8d60f24 --- /dev/null +++ b/frontend/src/components/MessageInput.tsx @@ -0,0 +1,70 @@ +import { useState, KeyboardEvent } from 'react'; +import './MessageInput.css'; + +const MAX_MESSAGE_LENGTH = 1000; + +interface MessageInputProps { + onSend: (content: string) => void; + disabled?: boolean; +} + +function MessageInput({ onSend, disabled }: MessageInputProps) { + const [message, setMessage] = useState(''); + + const handleSubmit = () => { + if (message.trim() && !disabled && message.length <= MAX_MESSAGE_LENGTH) { + onSend(message.trim()); + setMessage(''); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + const value = e.target.value; + // Allow input but enforce max length + if (value.length <= MAX_MESSAGE_LENGTH) { + setMessage(value); + } + }; + + const isOverLimit = message.length > MAX_MESSAGE_LENGTH * 0.9; + const charCountClass = isOverLimit ? 'char-count warning' : 'char-count'; + + return ( +
+
+