From dc5ebba3059c6a9f85bc43a69fbfb22773fc684a Mon Sep 17 00:00:00 2001 From: Micilini Roll Date: Sat, 9 May 2026 17:29:15 -0300 Subject: [PATCH] phase 05: add modern easy chat example --- README.zip | Bin 86271 -> 0 bytes examples/easy-chat/README.md | 88 ++++ examples/easy-chat/public/assets/app.js | 581 +++++++++++++++++++++ examples/easy-chat/public/assets/style.css | 380 ++++++++++++++ examples/easy-chat/public/index.html | 81 +++ examples/easy-chat/server.php | 23 + src/Chat/ChatKernel.php | 66 +++ src/Chat/PayloadValidator.php | 2 + src/Protocol/FrameCodec.php | 37 +- src/Server/ServerRuntime.php | 35 +- tests/Unit/Protocol/FrameCodecTest.php | 16 + 11 files changed, 1293 insertions(+), 16 deletions(-) delete mode 100644 README.zip create mode 100644 examples/easy-chat/README.md create mode 100644 examples/easy-chat/public/assets/app.js create mode 100644 examples/easy-chat/public/assets/style.css create mode 100644 examples/easy-chat/public/index.html create mode 100644 examples/easy-chat/server.php diff --git a/README.zip b/README.zip deleted file mode 100644 index 3ac9742050422c0b5b0ab53565f6612c01fba71d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 86271 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 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
+
+ + + + + +
+ + +
+
+
+ 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();