diff --git a/README.zip b/README.zip deleted file mode 100644 index 3ac9742..0000000 Binary files a/README.zip and /dev/null differ diff --git a/examples/easy-chat/README.md b/examples/easy-chat/README.md new file mode 100644 index 0000000..90aa4b8 --- /dev/null +++ b/examples/easy-chat/README.md @@ -0,0 +1,88 @@ +# EasyChat Example + +EasyChat is the first modern browser example for PHPSockets With WebSockets. + +It demonstrates a simple global chat using: + +- Native PHP WebSocket server. +- Composer autoload. +- PHP sockets. +- Chat core with unique display names. +- Plain HTML, CSS and JavaScript. +- Bootstrap through CDN. +- Safe message rendering with `textContent`. + +## Requirements + +From the project root, install dependencies first: + +```bash +composer install +``` + +The PHP `sockets` extension must be enabled. + +## Running the WebSocket server + +From the project root: + +```bash +php examples/easy-chat/server.php +``` + +By default, the WebSocket server runs at: + +```txt +ws://127.0.0.1:8080 +``` + +You can customize host and port with environment variables: + +```bash +PHPSOCKETS_HOST=127.0.0.1 PHPSOCKETS_PORT=8080 php examples/easy-chat/server.php +``` + +On Windows PowerShell: + +```powershell +$env:PHPSOCKETS_HOST="127.0.0.1" +$env:PHPSOCKETS_PORT="8080" +php examples/easy-chat/server.php +``` + +## Running the browser UI + +Open a second terminal and run: + +```bash +php -S 127.0.0.1:8000 -t examples/easy-chat/public +``` + +Then open: + +```txt +http://127.0.0.1:8000 +``` + +## Manual test + +Open two browser tabs: + +```txt +Tab 1: William +Tab 2: Ana +``` + +Expected behavior: + +- Both users should enter the chat. +- Both users should appear in the online users list. +- Messages sent by one tab should appear in the other tab. +- Duplicate display names should be rejected. +- User messages must be rendered safely without `innerHTML`. + +## Important notes + +This example is intentionally simple. + +It only demonstrates the global chat flow. Private direct messages and private group rooms will be demonstrated in later examples. diff --git a/examples/easy-chat/public/assets/app.js b/examples/easy-chat/public/assets/app.js new file mode 100644 index 0000000..d584e4c --- /dev/null +++ b/examples/easy-chat/public/assets/app.js @@ -0,0 +1,581 @@ +const state = { + socket: null, + currentUser: null, + users: new Map(), + typingUsers: new Map(), + typingTimers: new Map(), + isTyping: false, + typingStopTimer: null, + lastTypingStartSentAt: 0, + typingHeartbeatMs: 1000, + typingIdleStopMs: 1400, +}; + +const elements = { + alertBox: document.getElementById('alertBox'), + chatPanel: document.getElementById('chatPanel'), + connectionStatus: document.getElementById('connectionStatus'), + currentDisplayName: document.getElementById('currentDisplayName'), + displayNameInput: document.getElementById('displayNameInput'), + joinButton: document.getElementById('joinButton'), + joinForm: document.getElementById('joinForm'), + loginPanel: document.getElementById('loginPanel'), + messageForm: document.getElementById('messageForm'), + messageInput: document.getElementById('messageInput'), + messagesList: document.getElementById('messagesList'), + onlineCount: document.getElementById('onlineCount'), + serverUrlInput: document.getElementById('serverUrlInput'), + typingIndicator: document.getElementById('typingIndicator'), + usersList: document.getElementById('usersList'), +}; + +elements.joinForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const displayName = elements.displayNameInput.value.trim(); + const serverUrl = elements.serverUrlInput.value.trim(); + + if (!displayName || !serverUrl) { + showAlert('Display name and WebSocket server URL are required.', 'danger'); + return; + } + + connect(serverUrl, displayName); +}); + +elements.messageForm.addEventListener('submit', (event) => { + event.preventDefault(); + + const text = elements.messageInput.value.trim(); + + if (!text) { + stopTyping(); + return; + } + + clearLocalTypingStateBeforeSend(); + sendEnvelope('message.global', { text }); + elements.messageInput.value = ''; + elements.messageInput.focus(); +}); + +elements.messageInput.addEventListener('input', () => { + handleTypingInput(); +}); + +elements.messageInput.addEventListener('blur', () => { + stopTyping(); +}); + +window.addEventListener('beforeunload', () => { + stopTyping(); + + if (state.socket && state.socket.readyState === WebSocket.OPEN) { + state.socket.close(); + } +}); + +renderEmptyMessages(); +renderTypingIndicator(); +setStatus('Disconnected', 'offline'); + +function connect(serverUrl, displayName) { + disconnect(); + clearAlert(); + setStatus('Connecting', 'connecting'); + setJoinFormEnabled(false); + + try { + state.socket = new WebSocket(serverUrl); + } catch (error) { + setJoinFormEnabled(true); + setStatus('Disconnected', 'offline'); + showAlert('Invalid WebSocket server URL.', 'danger'); + return; + } + + state.socket.addEventListener('open', () => { + sendEnvelope('auth.join', { displayName }); + }); + + state.socket.addEventListener('message', (event) => { + handleServerMessage(event.data); + }); + + state.socket.addEventListener('close', () => { + const hadCurrentUser = Boolean(state.currentUser); + + setStatus('Disconnected', 'offline'); + + if (hadCurrentUser) { + showAlert('Connection closed. Start the server again and re-enter the chat.', 'warning'); + resetToLogin(false); + return; + } + + resetToLogin(true); + }); + + state.socket.addEventListener('error', () => { + setStatus('Connection error', 'offline'); + + if (!state.currentUser) { + setJoinFormEnabled(true); + } + + showAlert('Could not connect to the WebSocket server.', 'danger'); + }); +} + +function disconnect() { + if ( + state.socket && + (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING) + ) { + state.socket.close(); + } + + state.socket = null; +} + +function handleServerMessage(rawMessage) { + let envelope; + + try { + envelope = JSON.parse(rawMessage); + } catch (error) { + showAlert('The server sent an invalid JSON message.', 'danger'); + return; + } + + switch (envelope.type) { + case 'session.accepted': + handleSessionAccepted(envelope.payload); + break; + + case 'session.rejected': + handleSessionRejected(envelope.payload); + break; + + case 'presence.snapshot': + handlePresenceSnapshot(envelope.payload); + break; + + case 'presence.user_joined': + handleUserJoined(envelope.payload); + break; + + case 'presence.user_left': + handleUserLeft(envelope.payload); + break; + + case 'message.received': + handleMessageReceived(envelope.payload); + break; + + case 'typing.started': + handleTypingStarted(envelope.payload); + break; + + case 'typing.stopped': + handleTypingStopped(envelope.payload); + break; + + case 'error': + handleServerError(envelope.payload); + break; + + default: + showAlert(`Unsupported server event: ${envelope.type}`, 'warning'); + break; + } +} + +function handleSessionAccepted(payload) { + const session = payload.session; + + state.currentUser = session; + state.users.set(session.userId, session); + + elements.currentDisplayName.textContent = session.displayName; + elements.loginPanel.classList.add('d-none'); + elements.chatPanel.classList.remove('d-none'); + + setStatus('Connected', 'online'); + setJoinFormEnabled(true); + clearAlert(); + renderUsers(); + renderEmptyMessages(); + renderTypingIndicator(); + + elements.messageInput.focus(); +} + +function handleSessionRejected(payload) { + const message = payload.message || 'Could not enter the chat.'; + + disconnect(); + resetToLogin(true); + showAlert(message, 'danger'); +} + +function handlePresenceSnapshot(payload) { + const users = Array.isArray(payload.users) ? payload.users : []; + + state.users.clear(); + + for (const user of users) { + if (user && user.userId) { + state.users.set(user.userId, user); + } + } + + renderUsers(); +} + +function handleUserJoined(payload) { + const user = payload.user; + + if (user && user.userId) { + state.users.set(user.userId, user); + renderUsers(); + } +} + +function handleUserLeft(payload) { + if (payload.userId) { + state.users.delete(payload.userId); + clearTypingUser(payload.userId); + renderUsers(); + } +} + +function handleMessageReceived(payload) { + if (!payload.message) { + return; + } + + clearTypingUser(payload.message.fromUserId); + addMessage(payload.message); +} + +function handleTypingStarted(payload) { + if (!payload.userId || !payload.displayName) { + return; + } + + if (state.currentUser && payload.userId === state.currentUser.userId) { + return; + } + + state.typingUsers.set(payload.userId, payload.displayName); + + const currentTimer = state.typingTimers.get(payload.userId); + + if (currentTimer) { + window.clearTimeout(currentTimer); + } + + const timer = window.setTimeout(() => { + clearTypingUser(payload.userId); + }, 4000); + + state.typingTimers.set(payload.userId, timer); + renderTypingIndicator(); +} + +function handleTypingStopped(payload) { + if (!payload.userId) { + return; + } + + clearTypingUser(payload.userId); +} + +function handleServerError(payload) { + const message = payload.message || 'The server returned an error.'; + + if (!state.currentUser) { + disconnect(); + resetToLogin(true); + } + + showAlert(message, 'danger'); +} + +function sendEnvelope(type, payload) { + if (!state.socket || state.socket.readyState !== WebSocket.OPEN) { + showAlert('WebSocket connection is not open.', 'danger'); + return; + } + + state.socket.send(JSON.stringify({ type, payload })); +} + +function handleTypingInput() { + if (!state.currentUser) { + return; + } + + const text = elements.messageInput.value.trim(); + + if (!text) { + stopTyping(); + return; + } + + if (!state.isTyping) { + state.isTyping = true; + sendTypingStart(); + } else if (Date.now() - state.lastTypingStartSentAt >= state.typingHeartbeatMs) { + sendTypingStart(); + } + + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + } + + state.typingStopTimer = window.setTimeout(() => { + stopTyping(); + }, state.typingIdleStopMs); +} + +function sendTypingStart() { + state.lastTypingStartSentAt = Date.now(); + sendEnvelope('typing.start', { roomId: 'global' }); +} + +function stopTyping() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + if (!state.isTyping) { + return; + } + + state.isTyping = false; + state.lastTypingStartSentAt = 0; + + if (state.socket && state.socket.readyState === WebSocket.OPEN && state.currentUser) { + sendEnvelope('typing.stop', { roomId: 'global' }); + } +} + +function clearLocalTypingStateBeforeSend() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + state.isTyping = false; + state.lastTypingStartSentAt = 0; +} + +function resetToLogin(keepDisplayName) { + state.currentUser = null; + state.users.clear(); + clearTypingState(); + + elements.chatPanel.classList.add('d-none'); + elements.loginPanel.classList.remove('d-none'); + elements.currentDisplayName.textContent = '-'; + + if (!keepDisplayName) { + elements.displayNameInput.value = ''; + } + + setJoinFormEnabled(true); + renderUsers(); + renderEmptyMessages(); +} + +function setJoinFormEnabled(enabled) { + elements.displayNameInput.disabled = !enabled; + elements.serverUrlInput.disabled = !enabled; + elements.joinButton.disabled = !enabled; + elements.joinButton.textContent = enabled ? 'Enter Chat' : 'Connecting...'; +} + +function setStatus(label, mode) { + elements.connectionStatus.textContent = label; + elements.connectionStatus.classList.remove('status-online', 'status-offline', 'status-connecting'); + elements.connectionStatus.classList.add(`status-${mode}`); +} + +function showAlert(message, type) { + elements.alertBox.textContent = message; + elements.alertBox.className = `alert app-alert alert-${type}`; +} + +function clearAlert() { + elements.alertBox.textContent = ''; + elements.alertBox.className = 'alert app-alert d-none'; +} + +function renderUsers() { + elements.usersList.replaceChildren(); + elements.onlineCount.textContent = String(state.users.size); + + if (state.users.size === 0) { + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No users online yet.'; + elements.usersList.appendChild(empty); + return; + } + + const users = [...state.users.values()].sort((first, second) => { + return first.displayName.localeCompare(second.displayName); + }); + + for (const user of users) { + const item = document.createElement('div'); + item.className = 'user-item'; + + const avatar = document.createElement('div'); + avatar.className = 'user-avatar'; + avatar.textContent = user.displayName.slice(0, 1).toUpperCase(); + + const name = document.createElement('div'); + name.className = 'user-name'; + name.textContent = user.displayName; + + item.appendChild(avatar); + item.appendChild(name); + + if (state.currentUser && user.userId === state.currentUser.userId) { + const you = document.createElement('span'); + you.className = 'user-you'; + you.textContent = 'You'; + item.appendChild(you); + } + + elements.usersList.appendChild(item); + } +} + +function renderTypingIndicator() { + const names = [...state.typingUsers.values()]; + + if (names.length === 0) { + elements.typingIndicator.textContent = ''; + elements.typingIndicator.classList.add('d-none'); + return; + } + + elements.typingIndicator.textContent = `${formatTypingNames(names)} ${names.length === 1 ? 'is' : 'are'} typing`; + elements.typingIndicator.classList.remove('d-none'); +} + +function formatTypingNames(names) { + if (names.length === 1) { + return names[0]; + } + + if (names.length === 2) { + return `${names[0]} and ${names[1]}`; + } + + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +} + +function clearTypingUser(userId) { + if (!userId) { + return; + } + + const timer = state.typingTimers.get(userId); + + if (timer) { + window.clearTimeout(timer); + state.typingTimers.delete(userId); + } + + state.typingUsers.delete(userId); + renderTypingIndicator(); +} + +function clearTypingState() { + if (state.typingStopTimer) { + window.clearTimeout(state.typingStopTimer); + state.typingStopTimer = null; + } + + for (const timer of state.typingTimers.values()) { + window.clearTimeout(timer); + } + + state.typingUsers.clear(); + state.typingTimers.clear(); + state.isTyping = false; + state.lastTypingStartSentAt = 0; + renderTypingIndicator(); +} + +function renderEmptyMessages() { + elements.messagesList.replaceChildren(); + + const empty = document.createElement('div'); + empty.className = 'empty-state'; + empty.textContent = 'No messages yet. Start the conversation.'; + + elements.messagesList.appendChild(empty); +} + +function addMessage(message) { + const empty = elements.messagesList.querySelector('.empty-state'); + + if (empty) { + empty.remove(); + } + + const isOwn = state.currentUser && message.fromUserId === state.currentUser.userId; + const sender = findDisplayName(message.fromUserId); + const createdAt = formatTime(message.createdAt); + + const row = document.createElement('div'); + row.className = isOwn ? 'message-row is-own' : 'message-row'; + + const meta = document.createElement('div'); + meta.className = 'message-meta'; + meta.textContent = `${sender} • ${createdAt}`; + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + bubble.textContent = message.body || ''; + + row.appendChild(meta); + row.appendChild(bubble); + + elements.messagesList.appendChild(row); + elements.messagesList.scrollTop = elements.messagesList.scrollHeight; +} + +function findDisplayName(userId) { + const user = state.users.get(userId); + + if (!user) { + return 'Unknown user'; + } + + return user.displayName; +} + +function formatTime(value) { + if (!value) { + return 'now'; + } + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return 'now'; + } + + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/examples/easy-chat/public/assets/style.css b/examples/easy-chat/public/assets/style.css new file mode 100644 index 0000000..b2d907c --- /dev/null +++ b/examples/easy-chat/public/assets/style.css @@ -0,0 +1,380 @@ +:root { + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + background: + radial-gradient(circle at top left, rgba(72, 101, 255, 0.28), transparent 32rem), + radial-gradient(circle at bottom right, rgba(0, 214, 201, 0.18), transparent 28rem), + #080b13; + color: #f8fafc; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +.app-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 40px 0; +} + +.hero-card, +.panel { + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(15, 23, 42, 0.82); + box-shadow: 0 24px 90px rgba(0, 0, 0, 0.35); + backdrop-filter: blur(18px); +} + +.hero-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 28px; + border-radius: 28px; + margin-bottom: 18px; +} + +.hero-content h1 { + margin: 8px 0; + font-size: clamp(2.4rem, 5vw, 4.8rem); + line-height: 0.92; + letter-spacing: -0.08em; +} + +.hero-content p { + max-width: 720px; + margin: 0; + color: #a7b4cc; + font-size: 1.05rem; +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + color: #7dd3fc; + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.status-pill, +.count-pill { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.status-pill { + min-width: 150px; + padding: 12px 16px; +} + +.status-online { + border: 1px solid rgba(34, 197, 94, 0.4); + background: rgba(34, 197, 94, 0.14); + color: #86efac; +} + +.status-offline { + border: 1px solid rgba(248, 113, 113, 0.32); + background: rgba(248, 113, 113, 0.12); + color: #fca5a5; +} + +.status-connecting { + border: 1px solid rgba(251, 191, 36, 0.4); + background: rgba(251, 191, 36, 0.12); + color: #fde68a; +} + +.app-alert { + border-radius: 18px; + border: 0; +} + +.panel { + border-radius: 26px; + padding: 24px; +} + +.login-panel { + max-width: 520px; + margin: 0 auto; +} + +.panel-header { + margin-bottom: 20px; +} + +.panel-header.compact { + display: flex; + align-items: center; + justify-content: space-between; +} + +.panel-header h2, +.chat-header h2 { + margin: 0; + font-size: 1.35rem; + letter-spacing: -0.03em; +} + +.panel-header p { + margin: 8px 0 0; + color: #93a4bc; +} + +.form-label { + color: #cbd5e1; + font-weight: 700; +} + +.form-control { + border: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(2, 6, 23, 0.72); + color: #f8fafc; + border-radius: 16px; +} + +.form-control:focus { + border-color: rgba(96, 165, 250, 0.76); + background: rgba(2, 6, 23, 0.88); + color: #f8fafc; + box-shadow: 0 0 0 0.25rem rgba(59, 130, 246, 0.16); +} + +.form-control::placeholder { + color: #64748b; +} + +.btn-primary { + border: 0; + border-radius: 16px; + background: linear-gradient(135deg, #2563eb, #06b6d4); + font-weight: 800; + box-shadow: 0 16px 36px rgba(37, 99, 235, 0.28); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.chat-layout { + display: grid; + grid-template-columns: 310px minmax(0, 1fr); + gap: 18px; + min-height: 680px; +} + +.users-panel, +.chat-panel { + min-height: 680px; +} + +.count-pill { + min-width: 38px; + height: 32px; + background: rgba(14, 165, 233, 0.16); + color: #7dd3fc; +} + +.users-list { + display: grid; + gap: 10px; +} + +.user-item { + display: flex; + align-items: center; + gap: 12px; + min-height: 50px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 18px; + background: rgba(15, 23, 42, 0.72); +} + +.user-avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 50%; + background: rgba(59, 130, 246, 0.2); + color: #bfdbfe; + font-weight: 900; +} + +.user-name { + min-width: 0; + color: #e2e8f0; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-you { + margin-left: auto; + color: #7dd3fc; + font-size: 0.72rem; + font-weight: 900; + text-transform: uppercase; +} + +.chat-panel { + display: flex; + flex-direction: column; + padding: 0; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 24px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); +} + +.current-user { + color: #93a4bc; + font-size: 0.92rem; +} + +.current-user strong { + color: #f8fafc; +} + +.messages-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 14px; + padding: 24px; + overflow-y: auto; +} + +.empty-state { + display: grid; + place-items: center; + height: 100%; + color: #64748b; + text-align: center; +} + +.message-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} + +.message-row.is-own { + align-items: flex-end; +} + +.message-meta { + color: #64748b; + font-size: 0.78rem; + font-weight: 700; +} + +.message-bubble { + max-width: min(620px, 82%); + padding: 13px 15px; + border: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 18px 18px 18px 6px; + background: rgba(30, 41, 59, 0.86); + color: #f8fafc; + word-break: break-word; +} + +.message-row.is-own .message-bubble { + border-radius: 18px 18px 6px 18px; + background: linear-gradient(135deg, rgba(37, 99, 235, 0.95), rgba(8, 145, 178, 0.95)); +} + +.typing-indicator { + min-height: 42px; + padding: 0 24px 16px; + color: #93c5fd; + font-size: 0.92rem; + font-weight: 700; +} + +.typing-indicator::after { + content: ""; + display: inline-block; + width: 1.2em; + text-align: left; + animation: typingDots 1.2s steps(4, end) infinite; +} + +.message-form { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 18px; + border-top: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(2, 6, 23, 0.36); +} + +@keyframes typingDots { + 0% { + content: ""; + } + + 25% { + content: "."; + } + + 50% { + content: ".."; + } + + 75%, + 100% { + content: "..."; + } +} + +@media (max-width: 860px) { + .hero-card, + .chat-header { + align-items: flex-start; + flex-direction: column; + } + + .chat-layout { + grid-template-columns: 1fr; + } + + .users-panel, + .chat-panel { + min-height: auto; + } + + .chat-panel { + min-height: 620px; + } + + .message-form { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/examples/easy-chat/public/index.html b/examples/easy-chat/public/index.html new file mode 100644 index 0000000..8849fdb --- /dev/null +++ b/examples/easy-chat/public/index.html @@ -0,0 +1,81 @@ + + + + + + PHPSockets EasyChat + + + + +
+
+
+ PHPSockets With WebSockets +

EasyChat

+

A modern native PHP WebSocket chat example built with Composer, PHP sockets and plain JavaScript.

+
+ +
Disconnected
+
+ + + +
+
+

Enter the chat

+

Choose a unique display name. Duplicate names are blocked by the server.

+
+ + +
+ +
+ + +
+
+
+ Global room +

Public conversation

+
+ +
+ Signed in as - +
+
+ +
+ +
+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/examples/easy-chat/server.php b/examples/easy-chat/server.php new file mode 100644 index 0000000..5388932 --- /dev/null +++ b/examples/easy-chat/server.php @@ -0,0 +1,23 @@ +run(); \ No newline at end of file diff --git a/src/Chat/ChatKernel.php b/src/Chat/ChatKernel.php index 7b33aa0..f81950d 100644 --- a/src/Chat/ChatKernel.php +++ b/src/Chat/ChatKernel.php @@ -11,6 +11,7 @@ use Micilini\PhpSockets\Contracts\RoomStoreInterface; use Micilini\PhpSockets\Contracts\SessionStoreInterface; use Micilini\PhpSockets\Exceptions\InvalidPayloadException; +use Micilini\PhpSockets\Exceptions\UsernameAlreadyTakenException; use Micilini\PhpSockets\Protocol\Frame; use Micilini\PhpSockets\Protocol\Opcode; use Micilini\PhpSockets\Server\WebSocketServer; @@ -85,8 +86,12 @@ public function handleMessage( return; } + $messageType = null; + try { $envelope = MessageEnvelope::fromJson($frame->payload); + $messageType = $envelope->type; + $this->validator->assertEnvelope($envelope); match ($envelope->type) { @@ -95,9 +100,19 @@ public function handleMessage( 'message.direct' => $this->handleDirectMessage($connections, $connection, $envelope), 'room.create' => $this->handleRoomCreate($connections, $connection, $envelope), 'room.message' => $this->handleRoomMessage($connections, $connection, $envelope), + 'typing.start' => $this->handleTypingStatus($connections, $connection, 'typing.started'), + 'typing.stop' => $this->handleTypingStatus($connections, $connection, 'typing.stopped'), default => throw new InvalidPayloadException('Unsupported message type.'), }; } catch (Throwable $exception) { + if ($messageType === 'auth.join') { + $reason = $exception instanceof UsernameAlreadyTakenException ? 'username_taken' : 'join_failed'; + + $this->sendSessionRejected($connection, $reason, $exception->getMessage()); + + return; + } + $this->sendError($connection, $exception->getMessage()); } } @@ -136,6 +151,11 @@ private function handleGlobalMessage( $this->messages->save($message); + $this->broadcastAuthenticatedExcept($connections, $fromUserId, MessageEnvelope::server('typing.stopped', [ + 'userId' => $fromUserId, + 'roomId' => $room->id, + ])); + $this->broadcastAuthenticated($connections, MessageEnvelope::server('message.received', [ 'roomId' => $room->id, 'message' => $message->toArray(), @@ -220,11 +240,35 @@ private function handleClose(ConnectionRegistryInterface $connections, Connectio $this->presence->leave($userId); + $this->broadcastAuthenticated($connections, MessageEnvelope::server('typing.stopped', [ + 'userId' => $userId, + 'roomId' => 'global', + ])); + $this->broadcastAuthenticated($connections, MessageEnvelope::server('presence.user_left', [ 'userId' => $userId, ])); } + private function handleTypingStatus( + ConnectionRegistryInterface $connections, + Connection $connection, + string $eventType, + ): void { + $userId = $this->requireAuthenticated($connection); + $session = $this->sessions->findByUserId($userId); + + if (!$session instanceof UserSession) { + throw new InvalidPayloadException('Connection session was not found.'); + } + + $this->broadcastAuthenticatedExcept($connections, $userId, MessageEnvelope::server($eventType, [ + 'userId' => $userId, + 'displayName' => $session->displayName, + 'roomId' => 'global', + ])); + } + private function requireAuthenticated(Connection $connection): string { $userId = $connection->userId(); @@ -252,6 +296,14 @@ private function sendError(Connection $connection, string $message): void ])); } + private function sendSessionRejected(Connection $connection, string $reason, string $message): void + { + $this->sendEnvelope($connection, MessageEnvelope::server('session.rejected', [ + 'reason' => $reason, + 'message' => $message, + ])); + } + private function sendEnvelope(Connection $connection, MessageEnvelope $envelope): void { $connection->send($envelope->toJson()); @@ -266,6 +318,20 @@ private function broadcastAuthenticated(ConnectionRegistryInterface $connections } } + private function broadcastAuthenticatedExcept( + ConnectionRegistryInterface $connections, + string $exceptUserId, + MessageEnvelope $envelope, + ): void { + foreach ($connections->all() as $connection) { + $connectionUserId = $connection->userId(); + + if ($connectionUserId !== null && $connectionUserId !== $exceptUserId) { + $this->sendEnvelope($connection, $envelope); + } + } + } + /** * @param list $userIds */ diff --git a/src/Chat/PayloadValidator.php b/src/Chat/PayloadValidator.php index 192cc56..f04b747 100644 --- a/src/Chat/PayloadValidator.php +++ b/src/Chat/PayloadValidator.php @@ -17,6 +17,8 @@ final class PayloadValidator 'message.direct', 'room.create', 'room.message', + 'typing.start', + 'typing.stop', ]; public function assertEnvelope(MessageEnvelope $envelope): void diff --git a/src/Protocol/FrameCodec.php b/src/Protocol/FrameCodec.php index 61d7846..b89830f 100644 --- a/src/Protocol/FrameCodec.php +++ b/src/Protocol/FrameCodec.php @@ -17,12 +17,39 @@ public function __construct(private int $maxPayloadBytes = 65536) public function decode(string $data, bool $fromClient = true): Frame { - if (strlen($data) < 2) { + [$frame] = $this->decodeFrameAt($data, $fromClient, 0); + + return $frame; + } + + /** + * @return list + */ + public function decodeAll(string $data, bool $fromClient = true): array + { + $frames = []; + $offset = 0; + $length = strlen($data); + + while ($offset < $length) { + [$frame, $offset] = $this->decodeFrameAt($data, $fromClient, $offset); + $frames[] = $frame; + } + + return $frames; + } + + /** + * @return array{0: Frame, 1: int} + */ + private function decodeFrameAt(string $data, bool $fromClient, int $offset): array + { + if (strlen($data) < $offset + 2) { throw new ProtocolException('Incomplete WebSocket frame header.'); } - $firstByte = ord($data[0]); - $secondByte = ord($data[1]); + $firstByte = ord($data[$offset]); + $secondByte = ord($data[$offset + 1]); $fin = ($firstByte & 0x80) === 0x80; $reservedBits = $firstByte & 0x70; @@ -43,7 +70,7 @@ public function decode(string $data, bool $fromClient = true): Frame } $payloadLength = $secondByte & 0x7F; - $offset = 2; + $offset += 2; if ($payloadLength === 126) { $this->assertAvailableBytes($data, $offset, 2); @@ -100,7 +127,7 @@ public function decode(string $data, bool $fromClient = true): Frame $payload = self::applyMask($payload, $maskingKey); } - return new Frame($fin, $opcode, $payload, $masked); + return [new Frame($fin, $opcode, $payload, $masked), $offset + $payloadLength]; } public function encode(Frame $frame, bool $mask = false): string diff --git a/src/Server/ServerRuntime.php b/src/Server/ServerRuntime.php index 1d0dae2..a507b79 100644 --- a/src/Server/ServerRuntime.php +++ b/src/Server/ServerRuntime.php @@ -167,25 +167,38 @@ private function readConnection(Connection $connection): void } try { - $frame = $this->codec->decode($data); + $frames = $this->codec->decodeAll($data); - if ($frame->opcode === Opcode::PING) { - $connection->send(Frame::pong($frame->payload)); - return; + foreach ($frames as $frame) { + if (!$this->handleFrame($connection, $frame)) { + break; + } } - - if ($frame->opcode === Opcode::CLOSE) { - $this->closeConnection($connection); - return; - } - - $this->dispatcher->dispatch(new MessageReceived($connection, $frame)); } catch (Throwable $exception) { $this->dispatcher->dispatch(new ServerError($exception, $connection)); $this->closeConnection($connection); } } + private function handleFrame(Connection $connection, Frame $frame): bool + { + if ($frame->opcode === Opcode::PING) { + $connection->send(Frame::pong($frame->payload)); + + return true; + } + + if ($frame->opcode === Opcode::CLOSE) { + $this->closeConnection($connection); + + return false; + } + + $this->dispatcher->dispatch(new MessageReceived($connection, $frame)); + + return true; + } + private function closeConnection(Connection $connection): void { $connection->close(); diff --git a/tests/Unit/Protocol/FrameCodecTest.php b/tests/Unit/Protocol/FrameCodecTest.php index 67477a7..47a66e0 100644 --- a/tests/Unit/Protocol/FrameCodecTest.php +++ b/tests/Unit/Protocol/FrameCodecTest.php @@ -40,6 +40,22 @@ public function testClientTextFrameWithExtendedPayloadLengthIsDecoded(): void self::assertSame($payload, $frame->payload); } + public function testMultipleClientFramesInSingleBufferAreDecoded(): void + { + $codec = new FrameCodec(); + $firstPayload = '{"type":"typing.start","payload":{"roomId":"global"}}'; + $secondPayload = '{"type":"message.global","payload":{"text":"Hello"}}'; + + $frames = $codec->decodeAll( + $this->maskedFrame(Opcode::TEXT, $firstPayload) + . $this->maskedFrame(Opcode::TEXT, $secondPayload) + ); + + self::assertCount(2, $frames); + self::assertSame($firstPayload, $frames[0]->payload); + self::assertSame($secondPayload, $frames[1]->payload); + } + public function testServerTextFrameIsEncodedWithoutMask(): void { $codec = new FrameCodec();