그저 내가 되었고

🎯Node.js + Express:: 실시간 채팅 w/ Users & Rooms (- Ban & Kickout) 본문

개발/Node.js

🎯Node.js + Express:: 실시간 채팅 w/ Users & Rooms (- Ban & Kickout)

hyuunii 2022. 11. 24. 23:27

⚡️ 웹소켓 vs socket.io

웹소켓: 실시간 웹 서비스를 제공하기 위해 만들어진 소켓. 최근 구글닥스 등 여러 협업툴들에서 실시간 공동 편집 기능, 웹 메신저 등을 만들 때 많이 이용하는 기술. 점점 더 빈번히 사용되고 있음. but 모든 브라우저에서 동작하지는 못하기에 모든 사용자를 고려해야 하는 경우 실시간 기능 구현에 어려움이 있음.

socket.io: JS를 이용해 웹소켓 사용할 때 가장 많이 쓰는 라이브러리. 위에서 말한 브라우저의 한계에 극복하기 위해 socket.io는 웹소켓을 사용할 수 없는 브라우저는 polling기능(서버에서 데이터를 일정 간격마다 받아옴)으로 실시간 기능을 구현하게 해줌.

 

결론) 웹소켓 !== socket.io

➜ socket.io: 라이브러리(웹소켓 + 웹소켓 사용 못할때는 웹소켓과 비슷하게 사용하도록)


🌟 REAL TIME CHAT APP

❤︎Socket.io is basically a technology about using GIVE AND TAKE between back and front. Hence, you have to make not only backend code but froentend code either.

❤︎Sadly enough I'm not that talented of fullstack at all. So, I took css, html, etc frontend source codes from my good old friend called google.

 

chatcord/public/css/style.css

더보기
@import url('https://fonts.googleapis.com/css?family=Roboto&display=swap');

:root {
	--dark-color-a: #667aff;
	--dark-color-b: #7386ff;
	--light-color: #e6e9ff;
	--success-color: #5cb85c;
	--error-color: #d9534f;
}

* {
	box-sizing: border-box;
	margin: 0;
	padding: 0;
}

body {
	font-family: 'Roboto', sans-serif;
	font-size: 16px;
	background: var(--light-color);
	margin: 20px;
}

ul {
	list-style: none;
}

a {
	text-decoration: none;
}

.btn {
	cursor: pointer;
	padding: 5px 15px;
	background: var(--light-color);
	color: var(--dark-color-a);
	border: 0;
	font-size: 17px;
}

/* Chat Page */

.chat-container {
	max-width: 1100px;
	background: #fff;
	margin: 30px auto;
	overflow: hidden;
}

.chat-header {
	background: var(--dark-color-a);
	color: #fff;
	border-top-left-radius: 5px;
	border-top-right-radius: 5px;
	padding: 15px;
	display: flex;
	align-items: center;
	justify-content: space-between;
}

.chat-main {
	display: grid;
	grid-template-columns: 1fr 3fr;
}

.chat-sidebar {
	background: var(--dark-color-b);
	color: #fff;
	padding: 20px 20px 60px;
	overflow-y: scroll;
}

.chat-sidebar h2 {
	font-size: 20px;
	background: rgba(0, 0, 0, 0.1);
	padding: 10px;
	margin-bottom: 20px;
}

.chat-sidebar h3 {
	margin-bottom: 15px;
}

.chat-sidebar ul li {
	padding: 10px 0;
}

.chat-messages {
	padding: 30px;
	max-height: 500px;
	overflow-y: scroll;
}

.chat-messages .message {
	padding: 10px;
	margin-bottom: 15px;
	background-color: var(--light-color);
	border-radius: 5px;
	overflow-wrap: break-word;
}

.chat-messages .message .meta {
	font-size: 15px;
	font-weight: bold;
	color: var(--dark-color-b);
	opacity: 0.7;
	margin-bottom: 7px;
}

.chat-messages .message .meta span {
	color: #777;
}

.chat-form-container {
	padding: 20px 30px;
	background-color: var(--dark-color-a);
}

.chat-form-container form {
	display: flex;
}

.chat-form-container input[type='text'] {
	font-size: 16px;
	padding: 5px;
	height: 40px;
	flex: 1;
}

/* Join Page */
.join-container {
	max-width: 500px;
	margin: 80px auto;
	color: #fff;
}

.join-header {
	text-align: center;
	padding: 20px;
	background: var(--dark-color-a);
	border-top-left-radius: 5px;
	border-top-right-radius: 5px;
}

.join-main {
	padding: 30px 40px;
	background: var(--dark-color-b);
}

.join-main p {
	margin-bottom: 20px;
}

.join-main .form-control {
	margin-bottom: 20px;
}

.join-main label {
	display: block;
	margin-bottom: 5px;
}

.join-main input[type='text'] {
	font-size: 16px;
	padding: 5px;
	height: 40px;
	width: 100%;
}

.join-main select {
	font-size: 16px;
	padding: 5px;
	height: 40px;
	width: 100%;
}

.join-main .btn {
	margin-top: 20px;
	width: 100%;
}

@media (max-width: 700px) {
	.chat-main {
		display: block;
	}

	.chat-sidebar {
		display: none;
	}
}

 

chatcord/public/chat.html

더보기
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link
      rel="stylesheet"
      href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.12.1/css/all.min.css"
      integrity="sha256-mmgLkCYLUQbXn0B1SRqzHar6dCnv9oZFPEC1g1cwlkk="
      crossorigin="anonymous"
    />
    <link rel="stylesheet" href="css/style.css" />
    <title>ChatCord App</title>
  </head>
  <body>
    <div class="chat-container">
      <header class="chat-header">
        <h1><i class="fas fa-smile"></i> ChatCord</h1>
        <a id="leave-btn" class="btn">Leave Room</a>
      </header>
      <main class="chat-main">
        <div class="chat-sidebar">
          <h3><i class="fas fa-comments"></i> Room Name:</h3>
          <h2 id="room-name"></h2>
          <h3><i class="fas fa-users"></i> Users</h3>
          <ul id="users"></ul>
        </div>
        <div class="chat-messages"></div>
      </main>
      <div class="chat-form-container">
        <form id="chat-form">
          <input
            id="msg"
            type="text"
            placeholder="Enter Message"
            required
            autocomplete="off"
          />
          <button class="btn"><i class="fas fa-paper-plane"></i> Send</button>
        </form>
      </div>
    </div>

    <script
      src="https://cdnjs.cloudflare.com/ajax/libs/qs/6.9.2/qs.min.js"
      integrity="sha256-TDxXjkAUay70ae/QJBEpGKkpVslXaHHayklIVglFRT4="
      crossorigin="anonymous"
    ></script>
    <script src="/socket.io/socket.io.js"></script>
    <script src="js/main.js"></script>
  </body>
</html>

 

chatcord/public/index.html

더보기
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<meta http-equiv="X-UA-Compatible" content="ie=edge" />
		<link
			rel="stylesheet"
			href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.12.1/css/all.min.css"
			integrity="sha256-mmgLkCYLUQbXn0B1SRqzHar6dCnv9oZFPEC1g1cwlkk="
			crossorigin="anonymous"
		/>
		<link rel="stylesheet" href="css/style.css" />
		<title>ChatCord App</title>
	</head>
	<body>
		<div class="join-container">
			<header class="join-header">
				<h1><i class="fas fa-smile"></i> ChatCord</h1>
			</header>
			<main class="join-main">
				<form action="chat.html">
					<div class="form-control">
						<label for="username">Username</label>
						<input
							type="text"
							name="username"
							id="username"
							placeholder="Enter username..."
							required
						/>
					</div>
					<div class="form-control">
						<label for="room">Room</label>
						<select name="room" id="room">
							<option value="JavaScript">JavaScript</option>
							<option value="Python">Python</option>
							<option value="PHP">PHP</option>
							<option value="C#">C#</option>
							<option value="Ruby">Ruby</option>
							<option value="Java">Java</option>
						</select>
					</div>
					<button type="submit" class="btn">Join Chat</button>
				</form>
			</main>
		</div>
	</body>
</html>

 

 

1) npm i

 

 

2) npm i socket.io moment

 

 

3) npm i -D nodemon & To run nodemon, edit package.json(To run, npm dev run)

"scripts": {
  "start": "node server",
  "dev" : "nodemon server"
},

 

 

4) Make back server

const path = require('path');
const http = require('http');
const express = require('express');
const socketio = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = socketio(server);

//set static folder
app.use(express.static(path.join(__dirname, 'public')));

//run when a client connects
io.on('connection', socket => {
    console.log('new WS connection...')
})

const port = process.env.PORT || 3005;

server.listen(port, () => console.log(`Server running on port ${port}`));

 

 

5) I coded server.js to see some proof when the connection succeed, but there's nothing in front part that receive it. So let's write it down.

/public/chat.html's script

<script
        src="https://cdnjs.cloudflare.com/ajax/libs/qs/6.9.2/qs.min.js"
        integrity="sha256-TDxXjkAUay70ae/QJBEpGKkpVslXaHHayklIVglFRT4="
        crossorigin="anonymous"
></script>
<script src="/socket.io/socket.io.js"></script>  //f에서 socketio 라이브러리 접근 가능하게
<script src="js/main.js"></script>  //main.js에서 io method 등 필요한것 접근 하능하게

 

/public/js/main.js

const socket = io();   //io는 직전에 chat.html에서 script태그로 사용할 수 있게 만들어줬음

👆🏻위의 코드 한줄만으로 localhost:300X에서 chat.html로 접근(여기선 닉네임 적고 확인.)해서 새로고침 할때마다 터미널에 New WS Connection 찍힘

 

 

6) 프-백 확실하게 연결하고, 핑퐁하는 메세지 주고받아보기.

server.js

//run when a client connects
io.on('connection', socket => {
    console.log('new WS connection...')

    socket.emit('message', 'Welcome to ChatCord!');
})

👆🏻👇🏻이 코드들을 작성하면, 챗방에 들어가면(Connect 완) 서버 터미널에는 new WS connection... 찍힘 & 브라우저 콘솔에 Welcome to ChatCord가 찍힘

/public/js/main.js

const socket = io();

socket.on('message', messsage => {
    console.log(messsage)  //그니까,, 서버에서 message채널로 보낸 message를 받아서 콘솔에 찍으란 코드 
});

 

 

7) Use three ways to emit messages when a new client connects

server.js

//Run when a client connects
io.on('connection', socket => {
    //Three ways to send a messages

    //Welcome current user
    socket.emit('message', 'Welcome to ChatCord!');  //①Only send a message to a single client who connect

    //Broadcast when a user connects
    socket.broadcast.emit('message', 'A user has joined the chat');  //②Emit messages to everybody expect the user that's connecting

    //Runs when client disconnects
    socket.on('disconnect', () => {
        io.emit('message', 'A user has left the chat');  //③Emit messages to all the clients in general
    });
});

 

main.js

const socket = io();

socket.on('message', messsage => {
    console.log(messsage)
});

As you can see, it's same as before. 

Why? Because I only use the 'message' to emit a message from server, it would be perfectly received as 'message' in the frontend.

 

 

8) Alright. Now, let's write a message and send it. Right there's no reaction. Now, we have to code of emitting a message from browser, and then sending back it to browser so that we can put it into the dom(The Document Object Model, 웹 브라우저가 HTML 페이지를 인식하는 방법 & document 객체와 관련된 객체의 집합.)

 

main.js

//Message submit
chatForm.addEventListener('submit', (e) => {
    e.preventDefault();  //When you submit a form, it automatically it just submits to a file. But obviously we don't want it.

    //Get message text(When we submit the form, it should get the message from the text input)
    const msg = e.target.elements.msg.value;  //Basically, we're getting the message by it's id(Check the chat.html's input-id "msg")

    console.log(msg);
    
    //⚡️Till here, we just only get the message from browser. We don't emit it to server or anything.

 

Ok, if you checked out that it works wright, then instead of consolg logging, let's emit message to server.

console.log(msg);

from👆🏻, to👇🏻

//Emit message to server
socket.emit('chatMessage', msg);

 

 

9) Now, let's receive it in server side.

···
//Runs when client disconnects
    socket.on('disconnect', () => {
        io.emit('message', 'A user has left the chat');  //③Emit messages to all the clients in general
    });

    //Listen for chatMessage
    socket.on('chatMessage', (msg) => {
        console.log(msg)
    })
});

Again, if you certained that the message you sent on browser well logged in server's terminal, let's change the code to emit the message to browser.

//Listen for chatMessage
socket.on('chatMessage', (msg) => {
    io.emit('message', msg);
})

And you used 'message' channel, it also should logged 'on 'message'' part in main.js. I mean, here👇🏻

socket.on('message', messsage => {
    console.log(messsage)
});

Now, you can see the message is logged in browser console freaking well.. awesome.

 

 

10) But obviously, we don't want just log this. That's.. stupid😅 We wanna output it into the chat(window.)

You could use frontend framework like react or sth, but Imma doing this all vallina JS.

 

So, what we'll do is we'll have a function called 'outputMessage()', and we'll passing the message like 'outputMessage(message)'

 

main.js

//Message from server
socket.on('message', message => {
    console.log(message)
    outputMessage(message);
});

 

And then below all these frontend codes, let's put the outputMessage funciton.

// Output message to DOM
function outputMessage(message) {
    const div = document.createElement('div');
    div.classList.add('message');
    const p = document.createElement('p');
    p.classList.add('meta');
    p.innerText = message.username;
    p.innerHTML += `<span>${message.time}</span>`;
    div.appendChild(p);
    const para = document.createElement('p');
    para.classList.add('text');
    para.innerText = message.text;
    div.appendChild(para);
    document.querySelector('.chat-messages').appendChild(div);
}

((OK, now u might see the problem of UNDEFINED from undefined... Let's solve this prb below👊🏻👊🏻)

 

 

11) Touch up a little effct to jazz it up🎷

* Scroll down

main.js

const chatMessages = document.querySelector('.chat-messages');
//Message from server
socket.on('message', message => {
    console.log(message)
    outputMessage(message);

    //Everytime we get a message, do scroll down.
    chatMessages.scrollTop = chatMessages.scrollHeight;
});

 

* Clear input

main.js

//Message submit
chatForm.addEventListener('submit', e => {
    e.preventDefault();  //When you submit a form, it automatically it just submits to a file. But obviously we don't want it.

    //Get message text(When we submit the form, it should get the message from the text input)
    const msg = e.target.elements.msg.value;  //Basically, we're getting the message by it's id(Check the chat.html's input-id "msg")

    //Emit message to server
    socket.emit('chatMessage', msg);

    //Clear input
    e.target.elements.msg.value = '';
    e.target.elements.msg.focus();
})

 

 

12) Let's format the message. Cuz right now, it's always just a single string but I want it to be an object with a time.. Ultimately we want the user and the stuff like that.

 

So, let's go to server.js and I wanna wrap the format message function.

But first, let's make a directory calls 'utils' and and we're gonna make messages.js.

 

CHARCORD/utils/messages.js

const moment = require('moment');

function formatMessage(username, text) {
    return {
        username,
        text,
        time: moment().format('h:mm a')
    }
}

module.exports = formatMessage;

 

Now let's get back to server.js and add this👇🏻 code below all of the const lines.

const formatMessage = require('./utils/messages.js')

And replace this to all of the just simple 'message'.

//Run when a client connects
io.on('connection', socket => {
    //Three ways to send a messages

    //Welcome current user
    socket.emit('message', formatMessage('Admin',  'Welcome to ChatCord!'));  //①Only send a message to a single client who connect

    //Broadcast when a user connects
    socket.broadcast.emit('message', formatMessage('Admin', 'A user has joined the chat'));  //②Emit messages to everybody expect the user that's connecting

    //Runs when client disconnects
    socket.on('disconnect', () => {
        io.emit('message', formatMessage('Admin', 'A user has left the chat'));  //③Emit messages to all the clients in general
    });

    //Listen for chatMessage
    socket.on('chatMessage', (msg) => {
        io.emit('message', formatMessage('USER', msg));
    })
});

 

Now, you might can see a screen like this👇🏻 obviously it works pretty well,,, beautiful

 

 

13) Now, if you write your name down and enter to the chatroom, you can see your name and room name in url. 

By using QS(Query String) library, we're gonna grab those values and use it.

 

chat.html

<script
        src="https://cdnjs.cloudflare.com/ajax/libs/qs/6.9.2/qs.min.js"
        integrity="sha256-TDxXjkAUay70ae/QJBEpGKkpVslXaHHayklIVglFRT4="
        crossorigin="anonymous"
></script>
<script src="/socket.io/socket.io.js"></script>
<script src="js/main.js"></script>

 

The way to get those values is actually easy. 'Qs'is query string.

main.js

//Get username and room from URL
const { username, room } = Qs.parse(location.search, {
    ignoreQueryPrefix: true
});

 

 

14) Join  chatroom

main.js

//Join chatroom
socket.emit('joinRoom', { username, room })

 

server.js(Since we're using 'room', welcoming stuff should be inside of the 'joinRoom.'

io.on('connection', socket => {
    //Three ways to send a messages

    socket.on('joinRoom', ({ username, room }) => {
        //Welcome current user
        socket.emit('message', formatMessage('Admin',  'Welcome to ChatCord!'));  //①Only send a message to a single client who connect

        //Broadcast when a user connects
        socket.broadcast.emit('message', formatMessage('Admin', 'A user has joined the chat'));  //②Emit messages to everybody expect the user that's connecting
    });

Move disconnecting codes below the listening for chatmessage codes.

 //Listen for chatMessage
    socket.on('chatMessage', (msg) => {
        const user = getCurrentUser(socket.id);
        io.to(user.room).emit('message', formatMessage(user.username, msg));
    })

    //Runs when client disconnects
    socket.on('disconnect', () => {
        io.emit('message', formatMessage('Admin', 'A user has left the chat'));  //③Emit messages to all the clients in general
    });
});

 

 

15) Till now, we're just responding to the 'joinRoom'event. So, we need to actually create a user.

Before we do anything, let's create a new file in utils called users.js.

And then, anything to do with users will go in here, inside of the users.js, meaning like when they join, leave, if we wanna get all the users in a room... stuff like that.

 

users.js

const users = [];

//Join user to chat
function userJoin(id, username, room) {
    const user =  { id, username, room };

    users.push(user);

    return user;
}

//Get current user
function getCurrentUser(id) {
    return users.find(user => user.id === id);
}

module.exports = {
    userJoin,
    getCurrentUser
}

 

server.js

const formatMessage = require('./utils/messages.js')
const { userJoin, getCurrentUser } = require('./utils/users.js')
//Run when a client connects
io.on('connection', socket => {
    //Three ways to send a messages
    socket.on('joinRoom', ({ username, room }) => {
        const user = userJoin(socket.id, username, room);
        socket.join(user.room);
//Broadcast when a user connects
        socket.broadcast
            .to(user.room)  //Emit to a specific room
            .emit('message', formatMessage('Admin', `${user.username} has joined the chat`));  //②Emit messages to everybody expect the user that's connecting
    });
    //Listen for chatMessage
    socket.on('chatMessage', (msg) => {
        const user = getCurrentUser(socket.id);

        io.to(user.room).emit('message', formatMessage(user.username, msg));
    })
});

 

 

16) When user leaves

users.js

//User leaves chat
function userLeave(id) {
    const index = users.findIndex(user => user.id === id);

    if(index !== -1) {
        return users.splice(index, 1)[0];
    }
}

//Get room users
function getRoomUsers(room) {
    return users.filter(user => user.room === room);
}

module.exports = {
    userJoin,
    getCurrentUser,
    userLeave,
    getRoomUsers
}

 

server.js

const { userJoin, getCurrentUser, userLeave, getRoomUsers } = require('./utils/users.js')
//Broadcast when a user connects
        socket.broadcast
            .to(user.room)  //Emit to a specific room
            .emit('message', formatMessage('Admin', `${user.username} has joined the chat`));  //②Emit messages to everybody expect the user that's connecting

        //Send users and room info
        io.to(user.room).emit('roomUsers', {
            room: user.room,
            users: getRoomUsers(user.room)
        });
    });
//Runs when client disconnects
socket.on('disconnect', () => {
    const user = userLeave(socket.id);

    if (user) {
            io.to(user.room).emit('message', formatMessage('Admin', `${user.username} has left the chat`));  //③Emit messages to all the clients in general

            //Send users and room info
            io.to(user.room).emit('roomUsers', {
                room: user.room,
                users: getRoomUsers(user.room)
            });
        }
    });
});

 

main.js

const roomName = document.getElementById('room-name');
const userList = document.getElementById('users');
const socket = io();

//Join chatroom
socket.emit('joinRoom', { username, room })

//Get room and users
socket.on('roomUsers', ({ room, users }) => {
    outputRoomName(room);
    outputUsers(users);
})
//Add room name to DOM
function outputRoomName(room) {
    roomName.innerText = room;
}

//Add users to DOM
function outputUsers(users) {
    userList.innerHTML = `
    ${users.map(user => `<li>${user.username}</li>`).join('')}
    `;
}

//Prompt the user before leave chat room
document.getElementById('leave-btn').addEventListener('click', () => {
    const leaveRoom = confirm('Are you sure you want to leave the chatroom?');
    if (leaveRoom) {
        window.location = '../index.html';
    } else {
    }
});