diff --git a/package-lock.json b/package-lock.json index ce07e1dca..1f1192299 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,12 @@ "version": "1.0.0", "hasInstallScript": true, "license": "GPL-3.0", + "dependencies": { + "ws": "^8.20.0" + }, "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -1467,10 +1470,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.6.tgz", - "integrity": "sha512-b4om/whj4G9emyi84ORE3FRZzCRwRIesr8tJHXa8EvJdOaAPDpzcJ8A0sFfMsWH9NUOVmOwkBtOXDu5eZZ00Ig==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -8658,6 +8662,27 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 4f64337fe..8759e6b5f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "src/index.js", "scripts": { "init": "mate-scripts init", - "start": "node src/index.js", + "start": "node --watch src/index.js", "lint": "npm run format && mate-scripts lint", "format": "prettier --ignore-path .prettierignore --write './src/**/*.{js,ts}'", "test:only": "mate-scripts test", @@ -17,7 +17,7 @@ "license": "GPL-3.0", "devDependencies": { "@mate-academy/eslint-config": "latest", - "@mate-academy/scripts": "^1.8.6", + "@mate-academy/scripts": "^2.1.3", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", @@ -26,5 +26,8 @@ }, "mateAcademy": { "projectType": "javascript" + }, + "dependencies": { + "ws": "^8.20.0" } } diff --git a/src/index.js b/src/index.js index ad9a93a7c..a7b5a2b16 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,367 @@ 'use strict'; + +const fs = require('fs/promises'); +const http = require('http'); +const path = require('path'); +const { WebSocket, WebSocketServer } = require('ws'); + +const PORT = Number(process.env.PORT) || 3000; +const PUBLIC_DIR = path.join(__dirname, 'public'); + +const users = new Set(); +const rooms = new Map(); +let nextRoomId = 1; +let nextMessageId = 1; + +function createRoom(name) { + const id = String(nextRoomId); + + nextRoomId += 1; + + const room = { + id, + name: name.trim(), + messages: [], + }; + + rooms.set(id, room); + + return room; +} + +createRoom('General'); + +function broadcast(payload) { + const serializedPayload = JSON.stringify(payload); + + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(serializedPayload); + } + }); +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + + res.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +async function readJsonBody(req) { + return new Promise((resolve, reject) => { + let data = ''; + + req.on('data', (chunk) => { + data += chunk; + }); + + req.on('end', () => { + if (!data) { + resolve({}); + + return; + } + + try { + resolve(JSON.parse(data)); + } catch (error) { + reject(new Error('Invalid JSON body')); + } + }); + + req.on('error', reject); + }); +} + +function sanitizeText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim(); +} + +function getRoomList() { + return [...rooms.values()].map((room) => ({ + id: room.id, + name: room.name, + messagesCount: room.messages.length, + })); +} + +function getRoomById(roomId) { + return rooms.get(roomId); +} + +function isFallbackRoom(room) { + return room.name.toLowerCase() === 'general'; +} + +function matchRoomPath(urlPath) { + const roomMatch = urlPath.match(/^\/api\/rooms\/([^/]+)$/); + const roomMessagesMatch = urlPath.match(/^\/api\/rooms\/([^/]+)\/messages$/); + + return { + roomId: roomMatch ? roomMatch[1] : null, + roomMessagesId: roomMessagesMatch ? roomMessagesMatch[1] : null, + }; +} + +async function serveStaticFile(res, urlPath) { + const requestedPath = urlPath === '/' ? '/index.html' : urlPath; + const filePath = path.normalize(path.join(PUBLIC_DIR, requestedPath)); + + if (!filePath.startsWith(PUBLIC_DIR)) { + sendJson(res, 403, { message: 'Forbidden' }); + + return; + } + + try { + const fileContents = await fs.readFile(filePath); + const ext = path.extname(filePath); + const contentTypes = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + }; + const contentType = contentTypes[ext] || 'text/plain; charset=utf-8'; + + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Length': fileContents.length, + }); + res.end(fileContents); + } catch (error) { + sendJson(res, 404, { message: 'Not found' }); + } +} + +async function handleApi(req, res, urlPath) { + if (req.method === 'POST' && urlPath === '/api/users') { + try { + const { username } = await readJsonBody(req); + const normalizedUsername = sanitizeText(username); + + if (!normalizedUsername) { + sendJson(res, 400, { message: 'Username is required' }); + + return; + } + + users.add(normalizedUsername); + sendJson(res, 200, { username: normalizedUsername }); + } catch (error) { + sendJson(res, 400, { message: error.message }); + } + + return; + } + + if (req.method === 'GET' && urlPath === '/api/rooms') { + sendJson(res, 200, { rooms: getRoomList() }); + + return; + } + + if (req.method === 'POST' && urlPath === '/api/rooms') { + try { + const { name } = await readJsonBody(req); + const roomName = sanitizeText(name); + + if (!roomName) { + sendJson(res, 400, { message: 'Room name is required' }); + + return; + } + + const room = createRoom(roomName); + + sendJson(res, 201, { + room: { id: room.id, name: room.name, messagesCount: 0 }, + }); + + broadcast({ + type: 'rooms_updated', + rooms: getRoomList(), + }); + } catch (error) { + sendJson(res, 400, { message: error.message }); + } + + return; + } + + const { roomId, roomMessagesId } = matchRoomPath(urlPath); + + if (req.method === 'PATCH' && roomId) { + try { + const room = getRoomById(roomId); + + if (!room) { + sendJson(res, 404, { message: 'Room not found' }); + + return; + } + + const { name } = await readJsonBody(req); + const nextName = sanitizeText(name); + + if (!nextName) { + sendJson(res, 400, { message: 'Room name is required' }); + + return; + } + + room.name = nextName; + + sendJson(res, 200, { + room: { + id: room.id, + name: room.name, + messagesCount: room.messages.length, + }, + }); + + broadcast({ + type: 'rooms_updated', + rooms: getRoomList(), + }); + } catch (error) { + sendJson(res, 400, { message: error.message }); + } + + return; + } + + if (req.method === 'DELETE' && roomId) { + const room = getRoomById(roomId); + + if (!room) { + sendJson(res, 404, { message: 'Room not found' }); + + return; + } + + if (rooms.size === 1) { + sendJson(res, 400, { message: 'At least one room is required' }); + + return; + } + + if (isFallbackRoom(room)) { + sendJson(res, 400, { message: 'General room cannot be deleted' }); + + return; + } + + rooms.delete(roomId); + sendJson(res, 204, {}); + + broadcast({ + type: 'rooms_updated', + rooms: getRoomList(), + deletedRoomId: roomId, + }); + + return; + } + + if (req.method === 'GET' && roomMessagesId) { + const room = getRoomById(roomMessagesId); + + if (!room) { + sendJson(res, 404, { message: 'Room not found' }); + + return; + } + + sendJson(res, 200, { + messages: room.messages, + }); + + return; + } + + if (req.method === 'POST' && roomMessagesId) { + try { + const room = getRoomById(roomMessagesId); + + if (!room) { + sendJson(res, 404, { message: 'Room not found' }); + + return; + } + + const { author, text } = await readJsonBody(req); + const normalizedAuthor = sanitizeText(author); + const normalizedText = sanitizeText(text); + + if (!normalizedAuthor || !normalizedText) { + sendJson(res, 400, { + message: 'Message author and text are required', + }); + + return; + } + + const message = { + id: String(nextMessageId), + author: normalizedAuthor, + text: normalizedText, + time: new Date().toISOString(), + }; + + nextMessageId += 1; + room.messages.push(message); + sendJson(res, 201, { message }); + + broadcast({ + type: 'message_created', + roomId: room.id, + message, + rooms: getRoomList(), + }); + } catch (error) { + sendJson(res, 400, { message: error.message }); + } + + return; + } + + sendJson(res, 404, { message: 'API route not found' }); +} + +const server = http.createServer(async (req, res) => { + const hostHeader = req.headers.host || `localhost:${PORT}`; + const requestUrl = new URL(req.url || '/', `http://${hostHeader}`); + const urlPath = requestUrl.pathname; + + if (urlPath.startsWith('/api/')) { + await handleApi(req, res, urlPath); + + return; + } + + await serveStaticFile(res, urlPath); +}); + +const wss = new WebSocketServer({ server }); + +wss.on('connection', (socket) => { + socket.send( + JSON.stringify({ + type: 'rooms_updated', + rooms: getRoomList(), + }), + ); +}); + +server.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log(`Server started on http://localhost:${PORT}`); +}); diff --git a/src/public/app.js b/src/public/app.js new file mode 100644 index 000000000..cf590d4e1 --- /dev/null +++ b/src/public/app.js @@ -0,0 +1,412 @@ +'use strict'; +/* eslint-env browser */ + +const state = { + username: localStorage.getItem('chat:username') || '', + rooms: [], + activeRoomId: null, + activeMessages: [], +}; +let socket = null; + +const authSection = document.querySelector('#authSection'); +const chatSection = document.querySelector('#chatSection'); +const usernameForm = document.querySelector('#usernameForm'); +const usernameInput = document.querySelector('#usernameInput'); +const currentUserLabel = document.querySelector('#currentUserLabel'); +const createRoomButton = document.querySelector('#createRoomButton'); +const renameRoomButton = document.querySelector('#renameRoomButton'); +const deleteRoomButton = document.querySelector('#deleteRoomButton'); +const roomsList = document.querySelector('#roomsList'); +const currentRoomTitle = document.querySelector('#currentRoomTitle'); +const messagesList = document.querySelector('#messagesList'); +const messageForm = document.querySelector('#messageForm'); +const messageInput = document.querySelector('#messageInput'); + +function getActiveRoom() { + return state.rooms.find((room) => room.id === state.activeRoomId) || null; +} + +function syncRoomActionButtons() { + const activeRoom = getActiveRoom(); + const hasActiveRoom = Boolean(activeRoom); + const isGeneralRoom = activeRoom?.name?.toLowerCase() === 'general'; + + renameRoomButton.disabled = !hasActiveRoom; + deleteRoomButton.disabled = !hasActiveRoom || isGeneralRoom; +} + +async function request(url, options = {}) { + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + + if (response.status === 204) { + return null; + } + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || 'Request failed'); + } + + return data; +} + +function renderAuthState() { + const isAuthorized = Boolean(state.username); + + authSection.classList.toggle('hidden', isAuthorized); + chatSection.classList.toggle('hidden', !isAuthorized); + currentUserLabel.textContent = isAuthorized ? `User: ${state.username}` : ''; +} + +function renderRooms() { + roomsList.innerHTML = ''; + + state.rooms.forEach((room) => { + const listItem = document.createElement('li'); + const roomButton = document.createElement('button'); + + roomButton.type = 'button'; + roomButton.className = `room-item ${room.id === state.activeRoomId ? 'active' : ''}`; + roomButton.textContent = `${room.name} (${room.messagesCount})`; + + roomButton.addEventListener('click', () => { + joinRoom(room.id); + }); + + listItem.append(roomButton); + roomsList.append(listItem); + }); + + syncRoomActionButtons(); +} + +function renderMessages(messages) { + messagesList.innerHTML = ''; + + if (messages.length === 0) { + const emptyMessage = document.createElement('li'); + + emptyMessage.textContent = 'No messages yet'; + messagesList.append(emptyMessage); + + return; + } + + messages.forEach((message) => { + const listItem = createMessageItem(message); + + messagesList.append(listItem); + }); + + messagesList.scrollTop = messagesList.scrollHeight; +} + +function createMessageItem(message) { + const listItem = document.createElement('li'); + const meta = document.createElement('div'); + const text = document.createElement('div'); + + listItem.className = 'message'; + meta.className = 'message-meta'; + meta.textContent = `${message.author} at ${new Date(message.time).toLocaleString()}`; + text.textContent = message.text; + + listItem.append(meta, text); + + return listItem; +} + +function appendMessage(message) { + if (messagesList.textContent === 'No messages yet') { + messagesList.innerHTML = ''; + } + + const listItem = createMessageItem(message); + + messagesList.append(listItem); + messagesList.scrollTop = messagesList.scrollHeight; +} + +function isSameMessage(leftMessage, rightMessage) { + if (leftMessage?.id && rightMessage?.id) { + return leftMessage.id === rightMessage.id; + } + + return ( + leftMessage?.author === rightMessage?.author && + leftMessage?.text === rightMessage?.text && + leftMessage?.time === rightMessage?.time + ); +} + +async function loadRooms() { + const data = await request('/api/rooms'); + + state.rooms = data.rooms; + + if (!state.rooms.some((room) => room.id === state.activeRoomId)) { + state.activeRoomId = state.rooms[0]?.id || null; + } + + renderRooms(); +} + +function updateRoomsFromSocket(rooms, deletedRoomId = null) { + const previousActiveRoomId = state.activeRoomId; + + state.rooms = rooms; + + if (deletedRoomId && state.activeRoomId === deletedRoomId) { + state.activeRoomId = null; + } + + if (!state.rooms.some((room) => room.id === state.activeRoomId)) { + state.activeRoomId = state.rooms[0]?.id || null; + } + + renderRooms(); + + return previousActiveRoomId !== state.activeRoomId; +} + +async function loadMessages() { + if (!state.activeRoomId) { + state.activeMessages = []; + renderMessages(state.activeMessages); + currentRoomTitle.textContent = 'Room'; + + return; + } + + const room = state.rooms.find((item) => item.id === state.activeRoomId); + const data = await request(`/api/rooms/${state.activeRoomId}/messages`); + + currentRoomTitle.textContent = room?.name || 'Room'; + state.activeMessages = data.messages; + renderMessages(state.activeMessages); +} + +async function joinRoom(roomId) { + state.activeRoomId = roomId; + renderRooms(); + await loadMessages(); +} + +function connectWebSocket() { + if ( + socket && + (socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + + socket = new WebSocket(`${protocol}://${window.location.host}`); + + socket.addEventListener('message', async (messageEvent) => { + try { + const payload = JSON.parse(messageEvent.data); + + if (payload.type === 'rooms_updated') { + const activeRoomChanged = updateRoomsFromSocket( + payload.rooms || [], + payload.deletedRoomId, + ); + + if (activeRoomChanged) { + await loadMessages(); + } else { + const room = getActiveRoom(); + + currentRoomTitle.textContent = room?.name || 'Room'; + } + } + + if (payload.type === 'message_created') { + state.rooms = payload.rooms || state.rooms; + renderRooms(); + + if (payload.roomId === state.activeRoomId) { + const lastMessage = + state.activeMessages[state.activeMessages.length - 1]; + + if (!isSameMessage(lastMessage, payload.message)) { + state.activeMessages.push(payload.message); + appendMessage(payload.message); + } + } + } + } catch (error) { + // Ignore malformed ws payloads. + } + }); + + socket.addEventListener('close', () => { + setTimeout(() => { + if (state.username) { + connectWebSocket(); + } + }, 1000); + }); +} + +async function initializeChat() { + renderAuthState(); + + if (!state.username) { + usernameInput.focus(); + + return; + } + + await request('/api/users', { + method: 'POST', + body: JSON.stringify({ username: state.username }), + }); + + await loadRooms(); + await loadMessages(); + connectWebSocket(); +} + +usernameForm.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + + const username = usernameInput.value.trim(); + + if (!username) { + return; + } + + try { + await request('/api/users', { + method: 'POST', + body: JSON.stringify({ username }), + }); + + state.username = username; + localStorage.setItem('chat:username', username); + usernameInput.value = ''; + + await initializeChat(); + } catch (error) { + alert(error.message); + } +}); + +createRoomButton.addEventListener('click', async () => { + const roomName = prompt('New room name:'); + + if (!roomName || !roomName.trim()) { + return; + } + + try { + const data = await request('/api/rooms', { + method: 'POST', + body: JSON.stringify({ name: roomName }), + }); + + state.activeRoomId = data.room.id; + await loadRooms(); + await loadMessages(); + } catch (error) { + alert(error.message); + } +}); + +renameRoomButton.addEventListener('click', async () => { + if (!state.activeRoomId) { + return; + } + + const currentRoom = getActiveRoom(); + const nextName = prompt('Rename room to:', currentRoom?.name || ''); + + if (!nextName || !nextName.trim()) { + return; + } + + try { + await request(`/api/rooms/${state.activeRoomId}`, { + method: 'PATCH', + body: JSON.stringify({ name: nextName }), + }); + + await loadRooms(); + await loadMessages(); + } catch (error) { + alert(error.message); + } +}); + +deleteRoomButton.addEventListener('click', async () => { + if (!state.activeRoomId) { + return; + } + + const currentRoom = getActiveRoom(); + + if (currentRoom?.name?.toLowerCase() === 'general') { + alert('General room cannot be deleted'); + + return; + } + + const shouldDelete = confirm('Delete this room?'); + + if (!shouldDelete) { + return; + } + + try { + await request(`/api/rooms/${state.activeRoomId}`, { + method: 'DELETE', + }); + + state.activeRoomId = null; + await loadRooms(); + await loadMessages(); + } catch (error) { + alert(error.message); + } +}); + +messageForm.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + + if (!state.activeRoomId) { + return; + } + + const text = messageInput.value.trim(); + + if (!text) { + return; + } + + try { + await request(`/api/rooms/${state.activeRoomId}/messages`, { + method: 'POST', + body: JSON.stringify({ + author: state.username, + text, + }), + }); + + messageInput.value = ''; + } catch (error) { + alert(error.message); + } +}); + +initializeChat().catch((error) => { + alert(error.message); +}); diff --git a/src/public/index.html b/src/public/index.html new file mode 100644 index 000000000..8d9a3f8f4 --- /dev/null +++ b/src/public/index.html @@ -0,0 +1,77 @@ + + + + + + Node Chat + + + +
+
+

Node Chat

+

Enter your username to start chatting.

+
+ + + +
+
+ + +
+ + + + diff --git a/src/public/styles.css b/src/public/styles.css new file mode 100644 index 000000000..09332e5c7 --- /dev/null +++ b/src/public/styles.css @@ -0,0 +1,166 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: Arial, sans-serif; + background: #f5f7fb; + color: #202020; +} + +.app { + max-width: 960px; + margin: 0 auto; + min-height: 100vh; + padding: 24px; +} + +.hidden { + display: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.auth-section { + max-width: 420px; + margin: 100px auto 0; + text-align: center; + background: #fff; + border-radius: 12px; + padding: 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); +} + +.chat-section { + display: grid; + grid-template-columns: 260px 1fr; + gap: 16px; + min-height: calc(100vh - 48px); +} + +.rooms-panel, +.messages-panel { + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); + padding: 16px; +} + +.rooms-header, +.messages-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; +} + +.rooms-list, +.messages-list { + list-style: none; + margin: 0; + padding: 0; +} + +.rooms-list { + max-height: calc(100vh - 240px); + overflow: auto; + border: 1px solid #dde3f3; + border-radius: 8px; +} + +.room-item { + width: 100%; + border: 0; + background: transparent; + text-align: left; + padding: 10px 12px; + cursor: pointer; +} + +.room-item:hover { + background: #f1f5ff; +} + +.room-item.active { + background: #e3ecff; + font-weight: 700; +} + +.room-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.messages-panel { + display: flex; + flex-direction: column; +} + +.messages-list { + flex: 1; + min-height: 280px; + max-height: calc(100vh - 200px); + overflow: auto; + border: 1px solid #dde3f3; + border-radius: 8px; + padding: 12px; +} + +.message { + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid #edf1fb; +} + +.message:last-child { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.message-meta { + font-size: 12px; + color: #666; + margin-bottom: 4px; +} + +.inline-form { + display: flex; + gap: 8px; +} + +input, +button { + border: 1px solid #ccd4eb; + border-radius: 8px; + font-size: 14px; + padding: 10px 12px; +} + +input { + flex: 1; +} + +button { + background: #275efe; + color: #fff; + border: 0; + cursor: pointer; +} + +button:hover { + background: #1d4cd9; +}