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:
kappa
2026-01-19 10:17:27 +09:00
commit 554c578345
38 changed files with 10608 additions and 0 deletions

13
frontend/index.html Normal file
View 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
View 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
View 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
View 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
View 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;

View 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;
}
}

View 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;

View 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;

View 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;
}

View 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;

View 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;
}

View 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
View 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);
},
};

View 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
View 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
View 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>
);

View 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, '&lt;').replace(/>/g, '&gt;');
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 &#106;avascript:
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
View 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
View 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" }]
}

View 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
View 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,
},
},
},
});