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 <noreply@anthropic.com>
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Chat App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
106
frontend/src/App.css
Normal file
106
frontend/src/App.css
Normal file
@@ -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;
|
||||
}
|
||||
71
frontend/src/App.tsx
Normal file
71
frontend/src/App.tsx
Normal file
@@ -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 (
|
||||
<ErrorBoundary>
|
||||
<ChatRoom roomId={ROOM_CONFIG.defaultRoomId} userName={userName} onLeave={handleLeave} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="join-container">
|
||||
<div className="join-card">
|
||||
<h1>🔨 Anvil Lounge</h1>
|
||||
<p>Anvil Hosting 커뮤니티 채팅방</p>
|
||||
|
||||
<div className="nickname-display">
|
||||
<span className="nickname-label">당신의 닉네임</span>
|
||||
<div className="nickname-box">
|
||||
<span className="nickname">{userName}</span>
|
||||
<button type="button" className="reroll-button" onClick={handleReroll}>
|
||||
🎲
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="join-button" onClick={handleJoin}>
|
||||
입장하기
|
||||
</button>
|
||||
|
||||
<p className="powered-by">Powered by Anvil Hosting</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
131
frontend/src/components/ChatRoom.css
Normal file
131
frontend/src/components/ChatRoom.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
179
frontend/src/components/ChatRoom.tsx
Normal file
179
frontend/src/components/ChatRoom.tsx
Normal file
@@ -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<DisplayMessage[]>([]);
|
||||
const [users, setUsers] = useState<string[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="chat-room">
|
||||
<header className="chat-header">
|
||||
<div className="header-left">
|
||||
<button className="leave-button" onClick={onLeave}>
|
||||
← 나가기
|
||||
</button>
|
||||
</div>
|
||||
<div className="header-center">
|
||||
<h2>🔨 Anvil Lounge</h2>
|
||||
<span
|
||||
className={`connection-status ${connectionStatus}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={`연결 상태: ${connectionStatus === 'connected' ? '서버에 연결됨' : '서버 연결 중'}`}
|
||||
>
|
||||
{connectionStatus === 'connected' ? '연결됨' : '연결 중...'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="header-right">
|
||||
<span className="user-count">{users.length}명 참여중</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="chat-body">
|
||||
<aside className="user-list">
|
||||
<h3>참여자</h3>
|
||||
<ul>
|
||||
{users.map((user) => (
|
||||
<li key={user} className={user === userName ? 'me' : ''}>
|
||||
{user}
|
||||
{user === userName && ' (나)'}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
|
||||
<main className="messages-container">
|
||||
<MessageList messages={messages} />
|
||||
<MessageInput onSend={handleSendMessage} disabled={!isConnected} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatRoom;
|
||||
133
frontend/src/components/ErrorBoundary.tsx
Normal file
133
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||
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 (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
background: '#1a1a2e',
|
||||
color: '#eee',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '16px', color: '#e94560' }}>
|
||||
⚠️ 문제가 발생했습니다
|
||||
</h2>
|
||||
<p style={{ marginBottom: '24px', color: '#aaa' }}>
|
||||
예기치 않은 오류가 발생했습니다.
|
||||
<br />
|
||||
페이지를 새로고침하거나 다시 시도해주세요.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<button
|
||||
onClick={this.handleRetry}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
background: '#e94560',
|
||||
color: 'white',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
border: '2px solid #0f3460',
|
||||
borderRadius: '8px',
|
||||
background: 'transparent',
|
||||
color: '#eee',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
background: '#0f3460',
|
||||
borderRadius: '8px',
|
||||
textAlign: 'left',
|
||||
maxWidth: '100%',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<summary style={{ cursor: 'pointer', color: '#e94560' }}>
|
||||
개발자 정보
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
fontSize: '12px',
|
||||
color: '#aaa',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
{'\n\n'}
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
79
frontend/src/components/MessageInput.css
Normal file
79
frontend/src/components/MessageInput.css
Normal file
@@ -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;
|
||||
}
|
||||
70
frontend/src/components/MessageInput.tsx
Normal file
70
frontend/src/components/MessageInput.tsx
Normal file
@@ -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<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
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 (
|
||||
<div className="message-input">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? '연결 중...' : '메시지를 입력하세요... (Shift+Enter로 줄바꿈)'}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
maxLength={MAX_MESSAGE_LENGTH}
|
||||
aria-label="메시지 입력"
|
||||
aria-describedby="char-count"
|
||||
/>
|
||||
{message.length > 0 && (
|
||||
<span id="char-count" className={charCountClass} aria-live="polite">
|
||||
{message.length}/{MAX_MESSAGE_LENGTH}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !message.trim()}
|
||||
aria-label="메시지 전송"
|
||||
>
|
||||
전송
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageInput;
|
||||
147
frontend/src/components/MessageList.css
Normal file
147
frontend/src/components/MessageList.css
Normal file
@@ -0,0 +1,147 @@
|
||||
.message-list {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.message.mine {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-name {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-bottom: 4px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 70%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
background: #16213e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message.mine .message-bubble {
|
||||
background: #e94560;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message:not(.mine) .message-bubble {
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.system-message {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 20px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* Error message styles (for silence notifications, rate limits, etc.) */
|
||||
.error-message {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #fca5a5;
|
||||
padding: 10px 16px;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 12px;
|
||||
align-self: center;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
/* Bot message styles */
|
||||
.message.bot .message-name {
|
||||
color: #a78bfa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.message.bot .message-bubble {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 100%);
|
||||
border: 1px solid #4c1d95;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* Code block styles */
|
||||
.message-content code {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-content pre {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.message-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Markdown formatting */
|
||||
.message-content strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.message-content a {
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-content a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
103
frontend/src/components/MessageList.tsx
Normal file
103
frontend/src/components/MessageList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { markdownToHtml, escapeHtml } from '../utils/sanitize';
|
||||
import './MessageList.css';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
type: 'message' | 'system' | 'error';
|
||||
name?: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isMine?: boolean;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely render message content with markdown support for bot messages
|
||||
*/
|
||||
function MessageContent({ content, isBot }: { content: string; isBot?: boolean }) {
|
||||
const safeHtml = useMemo(() => {
|
||||
// Bot messages get markdown rendering
|
||||
if (isBot) {
|
||||
return markdownToHtml(content);
|
||||
}
|
||||
// User messages are plain text (escaped)
|
||||
return escapeHtml(content).replace(/\n/g, '<br>');
|
||||
}, [content, isBot]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="message-content"
|
||||
role={isBot ? 'article' : undefined}
|
||||
aria-label={isBot ? '봇 메시지 (서식 포함)' : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function MessageList({ messages }: MessageListProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const getMessageClass = (message: Message): string => {
|
||||
const classes = ['message', message.type];
|
||||
if (message.isMine) classes.push('mine');
|
||||
if (message.isBot) classes.push('bot');
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="message-list" ref={containerRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state" role="status" aria-live="polite">
|
||||
<p>아직 메시지가 없습니다.</p>
|
||||
<p>첫 메시지를 보내보세요!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={getMessageClass(message)}>
|
||||
{message.type === 'system' ? (
|
||||
<div className="system-message">{escapeHtml(message.content)}</div>
|
||||
) : message.type === 'error' ? (
|
||||
<div className="error-message" role="alert">
|
||||
{escapeHtml(message.content)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!message.isMine && (
|
||||
<div className="message-name">
|
||||
{message.isBot && <span className="bot-badge">🤖</span>}
|
||||
{message.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-bubble">
|
||||
<MessageContent content={message.content} isBot={message.isBot} />
|
||||
<div className="message-time">{formatTime(message.timestamp)}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MessageList;
|
||||
70
frontend/src/config.ts
Normal file
70
frontend/src/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Frontend configuration
|
||||
* Centralized constants and environment-aware settings
|
||||
*/
|
||||
|
||||
// Room Configuration
|
||||
export const ROOM_CONFIG = {
|
||||
defaultRoomId: 'anvil-lounge',
|
||||
} as const;
|
||||
|
||||
// Input Validation
|
||||
export const INPUT_CONFIG = {
|
||||
maxMessageLength: 2000, // Match backend validation.ts limit
|
||||
maxUserNameLength: 50,
|
||||
} as const;
|
||||
|
||||
// Chat Display Configuration
|
||||
export const CHAT_CONFIG = {
|
||||
maxDisplayMessages: 200, // Prevent memory leak from unbounded message array
|
||||
} as const;
|
||||
|
||||
// WebSocket Configuration
|
||||
export const WS_CONFIG = {
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectBaseDelayMs: 1000,
|
||||
reconnectMaxDelayMs: 30000,
|
||||
} as const;
|
||||
|
||||
// Nickname Generation
|
||||
export const NICKNAME_ADJECTIVES = [
|
||||
'배고픈', '졸린', '신난', '용감한', '수줍은', '호기심많은', '느긋한', '부지런한',
|
||||
'엉뚱한', '귀여운', '멋진', '웃긴', '똑똑한', '행복한', '명랑한', '씩씩한',
|
||||
'달리는', '춤추는', '노래하는', '코딩하는', '게이밍', '해킹하는', '디버깅하는'
|
||||
] as const;
|
||||
|
||||
export const NICKNAME_NOUNS = [
|
||||
'고양이', '강아지', '판다', '펭귄', '토끼', '여우', '곰돌이', '다람쥐',
|
||||
'햄스터', '수달', '알파카', '카피바라', '너구리', '미어캣', '레서판다',
|
||||
'개발자', '해커', '서버관리자', 'DevOps', '클라우더', '리눅서', '도커선장'
|
||||
] as const;
|
||||
|
||||
// Environment Detection
|
||||
export const isDevelopment = import.meta.env.DEV;
|
||||
export const isProduction = import.meta.env.PROD;
|
||||
|
||||
// Logger Configuration (for development debugging)
|
||||
export const LOG_CONFIG = {
|
||||
enableDebug: isDevelopment,
|
||||
enableInfo: isDevelopment,
|
||||
enableWarn: true,
|
||||
enableError: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Simple logger that respects environment
|
||||
*/
|
||||
export const logger = {
|
||||
debug: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableDebug) console.log('[DEBUG]', ...args);
|
||||
},
|
||||
info: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableInfo) console.log('[INFO]', ...args);
|
||||
},
|
||||
warn: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableWarn) console.warn('[WARN]', ...args);
|
||||
},
|
||||
error: (...args: unknown[]) => {
|
||||
if (LOG_CONFIG.enableError) console.error('[ERROR]', ...args);
|
||||
},
|
||||
};
|
||||
217
frontend/src/hooks/useWebSocket.ts
Normal file
217
frontend/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { logger, WS_CONFIG } from '../config';
|
||||
|
||||
export interface HistoryMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
isBot?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
type: 'message' | 'join' | 'leave' | 'userList' | 'error' | 'typing' | 'history';
|
||||
id?: string;
|
||||
name?: string;
|
||||
content?: string;
|
||||
message?: string; // For error messages from server
|
||||
timestamp: number;
|
||||
users?: string[];
|
||||
isBot?: boolean;
|
||||
isTyping?: boolean; // For typing indicator
|
||||
messages?: HistoryMessage[]; // For history type
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
roomId: string;
|
||||
userName: string;
|
||||
onMessage?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
sendMessage: (content: string) => void;
|
||||
sendTyping: (isTyping: boolean) => void;
|
||||
reconnect: () => void;
|
||||
isConnected: boolean;
|
||||
connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
export function useWebSocket({
|
||||
roomId,
|
||||
userName,
|
||||
onMessage,
|
||||
}: UseWebSocketOptions): UseWebSocketReturn {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number>();
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const isConnectingRef = useRef(false); // Prevent duplicate connection attempts
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
'connecting' | 'connected' | 'disconnected' | 'error'
|
||||
>('connecting');
|
||||
|
||||
const connect = useCallback(() => {
|
||||
// CRITICAL: Atomic lock acquisition to prevent race conditions
|
||||
// Must check and set in a single synchronous block before any async operation
|
||||
if (isConnectingRef.current) {
|
||||
logger.debug('Connection already in progress, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Acquire lock FIRST, before any other checks
|
||||
isConnectingRef.current = true;
|
||||
|
||||
// Now safely check existing WebSocket state
|
||||
const currentWs = wsRef.current;
|
||||
if (currentWs) {
|
||||
const state = currentWs.readyState;
|
||||
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) {
|
||||
// Release lock - connection already exists
|
||||
isConnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
// If CLOSING, wait for it to close before reconnecting
|
||||
if (state === WebSocket.CLOSING) {
|
||||
logger.debug('WebSocket is closing, waiting...');
|
||||
// Keep lock held, release when close event fires
|
||||
currentWs.addEventListener('close', () => {
|
||||
isConnectingRef.current = false;
|
||||
// Schedule reconnect on next tick to avoid recursion
|
||||
setTimeout(() => connect(), 0);
|
||||
}, { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
// Determine WebSocket URL based on environment
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = import.meta.env.DEV
|
||||
? 'localhost:8787'
|
||||
: 'chat-worker.kappa-d8e.workers.dev';
|
||||
const wsUrl = `${protocol}//${host}/api/rooms/${encodeURIComponent(roomId)}/websocket?name=${encodeURIComponent(userName)}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
logger.debug('WebSocket connected');
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: ChatMessage = JSON.parse(event.data);
|
||||
onMessage?.(message);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
logger.debug('WebSocket closed:', event.code, event.reason);
|
||||
setConnectionStatus('disconnected');
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// Clear any existing reconnection timeout to prevent memory leak
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
|
||||
// Don't reconnect if closed intentionally (code 1000)
|
||||
if (event.code === 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt reconnection with exponential backoff
|
||||
if (reconnectAttemptsRef.current < WS_CONFIG.maxReconnectAttempts) {
|
||||
const delay = Math.min(
|
||||
WS_CONFIG.reconnectBaseDelayMs * Math.pow(2, reconnectAttemptsRef.current),
|
||||
WS_CONFIG.reconnectMaxDelayMs
|
||||
);
|
||||
logger.debug(`Reconnecting in ${delay}ms...`);
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
} else {
|
||||
// Max retries exceeded - set permanent error state
|
||||
logger.error('Max reconnection attempts exceeded');
|
||||
setConnectionStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
logger.error('WebSocket error:', error);
|
||||
setConnectionStatus('error');
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
}, [roomId, userName, onMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
// Clear pending reconnection
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
// Close WebSocket gracefully
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Component unmounted');
|
||||
wsRef.current = null;
|
||||
}
|
||||
isConnectingRef.current = false;
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
const sendMessage = useCallback((content: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
content,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendTyping = useCallback((isTyping: boolean) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(
|
||||
JSON.stringify({
|
||||
type: 'typing',
|
||||
isTyping,
|
||||
})
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reconnect = useCallback(() => {
|
||||
// Reset reconnection attempts and try again
|
||||
reconnectAttemptsRef.current = 0;
|
||||
isConnectingRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = undefined;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'Manual reconnect');
|
||||
wsRef.current = null;
|
||||
}
|
||||
// Small delay to ensure clean state
|
||||
setTimeout(() => connect(), 100);
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
sendMessage,
|
||||
sendTyping,
|
||||
reconnect,
|
||||
isConnected: connectionStatus === 'connected',
|
||||
connectionStatus,
|
||||
};
|
||||
}
|
||||
19
frontend/src/index.css
Normal file
19
frontend/src/index.css
Normal file
@@ -0,0 +1,19 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
background-color: #1a1a2e;
|
||||
color: #eee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
195
frontend/src/utils/sanitize.ts
Normal file
195
frontend/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Sanitize HTML content to prevent XSS attacks
|
||||
* Allows safe HTML tags for formatting (bold, italic, code, links)
|
||||
*/
|
||||
export function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'code', 'pre', 'a', 'br', 'p', 'ul', 'ol', 'li'],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
||||
// Force all links to open in new tab with noopener
|
||||
ADD_ATTR: ['target', 'rel'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML special characters (for plain text display)
|
||||
*/
|
||||
export function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert basic markdown to safe HTML
|
||||
* Supports: **bold**, *italic*, `code`, ```code blocks```, links, lists, headers
|
||||
*
|
||||
* SECURITY: All content is escaped before any HTML tags are added
|
||||
*
|
||||
* Edge cases handled:
|
||||
* - Unmatched markers are left as-is
|
||||
* - Markers in middle of words (e.g., "file*.txt") are ignored
|
||||
* - Nested formatting (e.g., **bold _and_ italic**)
|
||||
*/
|
||||
export function markdownToHtml(text: string): string {
|
||||
// First, extract and protect code blocks from the ORIGINAL text
|
||||
// This prevents any HTML inside code blocks from being interpreted
|
||||
const codeBlocks: string[] = [];
|
||||
let processed = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
|
||||
const placeholder = `__CODE_BLOCK_${codeBlocks.length}__`;
|
||||
// Escape the code content BEFORE storing
|
||||
codeBlocks.push(`<pre><code>${escapeHtml(code.trim())}</code></pre>`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Extract and protect inline code - handle edge case of empty backticks
|
||||
const inlineCodes: string[] = [];
|
||||
processed = processed.replace(/`([^`]+)`/g, (_match, code) => {
|
||||
// Skip if content is empty after trim
|
||||
if (!code.trim()) return _match;
|
||||
const placeholder = `__INLINE_CODE_${inlineCodes.length}__`;
|
||||
// Escape the code content BEFORE storing
|
||||
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Now escape the remaining HTML
|
||||
let html = escapeHtml(processed);
|
||||
|
||||
// Restore code blocks (already escaped and wrapped)
|
||||
codeBlocks.forEach((block, i) => {
|
||||
html = html.replace(`__CODE_BLOCK_${i}__`, block);
|
||||
});
|
||||
|
||||
// Restore inline codes (already escaped and wrapped)
|
||||
inlineCodes.forEach((code, i) => {
|
||||
html = html.replace(`__INLINE_CODE_${i}__`, code);
|
||||
});
|
||||
|
||||
// Headers (## title) - must be at start of line
|
||||
html = html.replace(/^### (.+)$/gm, '<strong><em>$1</em></strong>');
|
||||
html = html.replace(/^## (.+)$/gm, '<strong>$1</strong>');
|
||||
|
||||
// Bold - requires word boundary or start/end to avoid matching inside words
|
||||
// Match **text** where text is not empty and doesn't contain newlines
|
||||
html = html.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic - match *text* but not **text** (already handled above)
|
||||
// Also avoid matching * in the middle of words like "file*.txt"
|
||||
html = html.replace(/(?<!\*)\*([^*\n]+?)\*(?!\*)/g, '<em>$1</em>');
|
||||
|
||||
// Unordered lists - lines starting with "- " or "* "
|
||||
html = html.replace(/^[-*] (.+)$/gm, '• $1');
|
||||
|
||||
// Ordered lists - lines starting with "1. ", "2. ", etc.
|
||||
html = html.replace(/^\d+\. (.+)$/gm, '• $1');
|
||||
|
||||
// Links (auto-detect URLs) - URLs are not escaped by escapeHtml
|
||||
// Also handle markdown-style links [text](url)
|
||||
// SECURITY FIX: Comprehensive URL validation against encoded attacks
|
||||
html = html.replace(
|
||||
/\[([^\]]+)\]\(([^)\s]+)\)/g,
|
||||
(_match, linkText, url) => {
|
||||
// Validate URL using comprehensive security check
|
||||
if (!isUrlSafe(url)) {
|
||||
return _match; // Return original text, don't create link
|
||||
}
|
||||
// Ensure link text doesn't contain unescaped HTML (should already be escaped)
|
||||
const safeText = linkText.replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Plain URLs (not already in links)
|
||||
// SECURITY FIX: Comprehensive URL validation
|
||||
html = html.replace(
|
||||
/(?<!href=")(?<!">)(https?:\/\/[^\s<>"']+)/g,
|
||||
(_match, url) => {
|
||||
// Validate URL using comprehensive security check
|
||||
if (!isUrlSafe(url)) {
|
||||
return _match;
|
||||
}
|
||||
return `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`;
|
||||
}
|
||||
);
|
||||
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return sanitizeHtml(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL is safe for use in href attribute
|
||||
* Checks for dangerous protocols and encoded attacks
|
||||
*/
|
||||
function isUrlSafe(url: string): boolean {
|
||||
try {
|
||||
// Decode URL to catch encoded attacks like javascript:
|
||||
const decoded = decodeURIComponent(url);
|
||||
const doubleDecoded = decodeURIComponent(decoded); // Handle double encoding
|
||||
|
||||
// Check both original and decoded versions
|
||||
const toCheck = [url, decoded, doubleDecoded].map(s => s.toLowerCase());
|
||||
|
||||
// Dangerous protocol patterns (case-insensitive, handles encoding)
|
||||
const dangerousProtocols = [
|
||||
/^javascript:/,
|
||||
/^vbscript:/,
|
||||
/^data:/,
|
||||
/^file:/,
|
||||
/^about:/,
|
||||
/^blob:/,
|
||||
];
|
||||
|
||||
for (const str of toCheck) {
|
||||
// Remove whitespace that could bypass checks
|
||||
const cleaned = str.replace(/\s/g, '');
|
||||
|
||||
for (const pattern of dangerousProtocols) {
|
||||
if (pattern.test(cleaned)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTML entities that could form dangerous protocols
|
||||
if (/&#/.test(str) && /script|data|file|about|blob|vbscript/i.test(cleaned)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Only allow http and https protocols
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for dangerous characters that could break out of attributes
|
||||
if (/["'<>`]/.test(url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
// If URL decoding fails, it's suspicious - reject it
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains potentially dangerous patterns
|
||||
*/
|
||||
export function containsUnsafeContent(text: string): boolean {
|
||||
const dangerousPatterns = [
|
||||
/<script/i,
|
||||
/javascript:/i,
|
||||
/on\w+=/i, // onclick, onerror, etc.
|
||||
/data:/i,
|
||||
/<iframe/i,
|
||||
/<object/i,
|
||||
/<embed/i,
|
||||
];
|
||||
|
||||
return dangerousPatterns.some(pattern => pattern.test(text));
|
||||
}
|
||||
11
frontend/src/vite-env.d.ts
vendored
Normal file
11
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly DEV: boolean;
|
||||
readonly PROD: boolean;
|
||||
readonly MODE: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user