Un chat creado con NodeJS, Socket.io, Express.js y Vanilla JS
Un chat creado con NodeJS, Socket.io, Express.js y Vanilla JS
Con esta nueva entrada quiero mostrar como he creado un chat usando NodeJS y Socket.io. Para realizar el chat he usado la guía que ofrece la librería socket.io y su extensa documentación.
El código del proyecto
Para realizar este pequeño proyecto no he usado ningún framework basado en Javascript, ya que en principio iba a ser una prueba de concepto con pocas líneas para entender como funciona la librería socket.io.
Revisando la documentación oficial de socket.io se puede desarrollar un chat básico sin problema, pero como la idea era investigar, he probado algunas cosas añadiendo las siguientes funcionalidades:
- Subida de ficheros
- Chat privados
- Información visual sobre los nuevos mensajes.
- Cerrar y tener salas privadas
A continuación veremos el código correspondiente a la vista que he realizado usando bulma, el cual aconsejo echar un ojo ya que además de ligero, es bastante completo tanto en la documentación como en la cantidad de ejemplos de código.
index.html
<!DOCTYPE html> <html lang="es"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>CHAT</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/all.min.css" /> <link rel="stylesheet" href="./style.css"> </head> <body> <div class="notification hidden"> <p></p> </div> <div class="container"> <div class="columns"> <div class="column is-one-quarter mt-6"> <div class="card has-background-black"> <div class="card-content"> <div class="media mb-0"> <div class="media-content"> <p class="title is-6 has-text-white">Usuarios conectados</p> <hr> </div> </div> <div class="content has-text-white" id="userConnected"> </div> </div> </div> </div> <div class="column"> <div class="tabs is-boxed pt-6 mb-0"> <ul> <li id="tabGeneral" data-content="chatGeneral"> <a class="active"> <span class="has-text-white">General</span> <i class="fas fa-comment newMessage hide"></i> </a> </li> </ul> </div> <div class="columns"> <div class="column containerChats pb-0"> <div id="chatGeneral" class="chat textarea contentTab"></div> <div class="infoInput"></div> </div> </div> <div class="columns is-mobile"> <div class="column is-one-quarter pr-0 pt-1"> <input class="input has-text-white has-background-black" type="text" id="user" placeholder="Usuario"> </div> <div class="column ml-1 mt-1 mr-3"> <div class="columns is-mobile"> <input class="input has-text-white has-background-black" type="text" id="message" placeholder="Mensaje" title="Subir fichero"> <form> <button class="button is-info" id="btnUploadFile"> <i class="fas fa-file-upload"></i> <input class="file-input" type="file" name="uploadFile" accept="image/x-png,image/gif,image/jpeg"> </button> </form> </div> </div> </div> <div class="columns is-mobile hidden" id="containerProgress"> <div class="column"> <progress id="upload-progress-bar" max="100" value="0">0</progress> <div class="info-progress" /> </div> </div> </div> </div> </div> <script src="/socket.io/socket.io.js"></script> <script src="./index.js"></script> </body> </html>
style.css
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300&display=swap'); html { overflow: hidden; } body { font-family: 'Montserrat', sans-serif; height: 100vh; width: 100vw; margin: 0; padding: 0; background: #354b52; color: #ffffff; overflow: hidden; } input { padding: 10px; font-size: 14px; } .chat { width: 100%; height: 250px; padding: 10px; font-size: 14px; overflow-y: auto; border: 1px solid #dddddd; background: #000000; color: #ffffff; resize: none !important; } .infoInput { font-size: 11px; position: relative; left: 10px; top: -5px; } .disabled { pointer-events: none; color: #aaaaaa !important; } .chatUser { font-weight: bold; margin-right: 5px; } ::placeholder { color: #ffffff !important; opacity: .3 !important; } .chat::-webkit-scrollbar, #userConnected::-webkit-scrollbar, .tabs ul::-webkit-scrollbar { width: 10px; } .chat::-webkit-scrollbar-track, #userConnected::-webkit-scrollbar-track, .tabs ul::-webkit-scrollbar-track { border-radius: 10px; } .chat::-webkit-scrollbar-thumb, #userConnected::-webkit-scrollbar-thumb, .tabs ul::-webkit-scrollbar-thumb { background: #777777; border-radius: 3px; } .chat::-webkit-scrollbar-thumb:hover, #userConnected::-webkit-scrollbar-thumb:hover, .tabs ul::-webkit-scrollbar-thumb:hover { background: #ffffff; cursor: pointer; } .tabs ul { border-bottom: 0 !important; font-size: 14px; overflow: auto; } .tabs.is-boxed a:hover, .active { background-color: #000000; border: 1px solid #ffffff; } .containerChats .infoInput { text-align: right; left: 0; top: 0px; height: 15px; } .hide { display: none; } .card { border: 1px solid #dddddd; max-height: 343px; height: 343px; } .notification { transition: 300ms ease-in-out; position: fixed; bottom: -24px; width: 100%; opacity: 1; z-index: 10; } .notification.hidden { transition: 300ms ease-in-out; bottom: -100px; opacity: 0; z-index: -1; } #userConnected { overflow-y: auto; max-height: 225px; height: 225px; } #userConnected .chatUser { cursor: pointer !important; } @media (max-width: 768px) { .tabs { padding-top: 0px !important; } } .newMessage { font-size: 11px; left: 5px; position: relative; color: #2196F3; } .closeChat { position: relative; left: 12px; top: -11px; color: #d46a10; font-size: 10px; } @media all and (max-width: 1023px) { .container { max-width: 960px; margin-left: 32px !important; margin-right: 32px !important; } } progress { width: 100%; position: relative; bottom: 25px; transition: 200ms ease-in-out; opacity: 1; } .info-progress { font-size: 11px; text-align: right; width: 100%; bottom: 30px; position: relative; } .hidden { opacity: 0; transition: 200ms ease-in-out; }
Sobre el código anterior no hay mucho que añadir, una página simple pero suficiente para la prueba de concepto.
El último trozo de código que veremos, es el encargado de que la aplicación funcione en nuestro navegador.
index.js
// Definición de constantes y variables para la gestión del chat let socket = null; let user = null; let message = null; let usersPanel = null; let notification = null; let uploadFile = null; let webrtc = null; const CHAT_GENERAL = 'chatGeneral'; const LENGTH_MIN_USERNAME = 3; const EMPTY = 0; const LITERAL = { sameUser: 'No puedes enviarte un mensaje a ti mismo', minSizeUser: `El nombre del usuario debe tener <b>mínimo ${LENGTH_MIN_USERNAME} caracteres</b>`, uploadFile: 'Ha compartido un fichero', uploadSuccess: 'El fichero se ha subido y se esta compartiendo correctamente', }; /** * @description Cierra una sala de chat privada * @param {string} selectorID ID de la sala a cerrar */ const closeChat = selectorID => { document.querySelector(`.tabs ul li[data-content='${selectorID}']`).removeEventListener('click', selectedChat); document.querySelector(`.tabs ul li[data-content='${selectorID}']`).remove(); document.querySelector(`.containerChats #${selectorID}`).remove(); document.querySelector(`li[data-content='chatGeneral']`).click(); }; /** * @description Creamos una sala privada * @param {object} data Información para crear la sala */ const chatTo = data => { const selectorID = data.idHTML || data.idOrigenHTML; const selectorIdUser = data.idUser ||data.idOrigen; const selectorUser = data.user || data.userOrigen; const existChat = document.querySelectorAll(`#${selectorID}`).length; if (existChat === EMPTY) { if (user.dataset.idhtml !== selectorID || data.idDestinoHTML !== data.idOrigenHTML) { document.querySelectorAll('.tabs ul li').forEach(item => { item.children[0].classList.remove('active'); }); document.querySelectorAll('.containerChats .chat').forEach(item => { item.classList.add('hide'); }); const chat = document.createElement('div'); const li = document.createElement('li'); const a = document.createElement('a'); const span = document.createElement('span'); const iNewChat = document.createElement('i'); const iCloseChat = document.createElement('i'); chat.setAttribute('id', selectorID); chat.classList.add('chat','textarea', 'contentTab'); document.querySelector('.containerChats').prepend(chat); li.setAttribute('data-content', selectorID.toString()); li.setAttribute('data-idUser', selectorIdUser.toString()); a.classList.add('active'); span.style.color = data.color; span.classList.add('has-text-white', 'chatUser'); span.innerHTML = selectorUser; a.appendChild(span); iNewChat.classList.add('fas', 'fa-comment', 'newMessage', 'hide'); a.appendChild(iNewChat); iCloseChat.classList.add('fas', 'fa-times-circle', 'closeChat'); iCloseChat.onclick = () => { closeChat(selectorID.toString()); }; a.appendChild(iCloseChat); li.appendChild(a); document.querySelector('.tabs ul').appendChild(li); document.querySelector(`.tabs ul li[data-content='${selectorID}']`).addEventListener('click', selectedChat); } else { notify(LITERAL.sameUser, 'danger'); } } }; /** * @description Seleccionar una sala para charlar * @param {object} evt Evento implicito en la acción ejecutada */ const selectedChat = evt => { document.querySelectorAll('.containerChats .chat').forEach(item => { item.classList.add('hide'); }); const showChat = evt.currentTarget.dataset['content']; const chatElement = document.querySelector(`#${showChat}`); if (chatElement) { chatElement.classList.remove('hide'); } document.querySelectorAll('.tabs a').forEach(item => item.classList.remove('active')); evt.currentTarget.children[0].classList.add('active'); if (!Array.from(evt.currentTarget.children[0].querySelector('i').classList).includes('hide')) { evt.currentTarget.children[0].querySelector('i').classList.add('hide') } }; /** * @description Envia mensajes al chat general y a un usuario privado * @param {object} evt Evento implicito en la acción ejecutada * @param {boolean} status Indicamos el estado para saber que usuario esta escribiendo */ const sendMessage = (evt, status) => { if (evt.key === 'Enter') { if (user.value.length >= LENGTH_MIN_USERNAME && message.value.trim().length > EMPTY) { const parent = document.querySelectorAll('.tabs a.active')[0].parentElement; const content = parent.dataset.content; const currentlyChat = document.querySelector(`#${content}`); const isChatGeneral = content === CHAT_GENERAL; const infoMensaje = { idOrigen: user.dataset.iduser, idOrigenHTML: user.dataset.idhtml, idDestino: isChatGeneral ? CHAT_GENERAL : parent.dataset.iduser, idDestinoHTML: content, userOrigen: user.value, userDestino: isChatGeneral ? CHAT_GENERAL : parent.innerText, message: message.value }; if (content !== CHAT_GENERAL) { const color = document.querySelector(`#userConnected .chatUser[data-idhtml='${user.dataset.idhtml}']`).style.color; const messageSend = document.querySelector(`.containerChats #${content}`); const div = document.createElement('div'); const span = document.createElement('span'); span.style.color = color; span.classList.add('chatUser'); span.appendChild(document.createTextNode(`${infoMensaje.userOrigen}:`)); div.appendChild(span); div.innerHTML += infoMensaje.message; messageSend.appendChild(div); } socket.emit(`message-chat-${isChatGeneral ? 'general' : 'private'}`, infoMensaje); message.value = ''; currentlyChat.scrollTo(0, currentlyChat.scrollHeight); } user.classList[user.value.length >= LENGTH_MIN_USERNAME ? 'remove' : 'add']('has-background-danger'); } else { if (user.value.length >= LENGTH_MIN_USERNAME) { if (evt.key !== 'Tab') { socket.emit('write-client', { data: status ? user.value : ''}); } } } }; /** * @description Muestra texto indicando quien esta escribiendo * @param {object} payload Información que pinta cuando pulsamos un tecla */ const clientBeenWriting = payload => { document.querySelector('.containerChats .infoInput').innerHTML = payload; }; /** * @description Muestra texto en el chat general * @param {object} data Información relativa al usuario */ const reciveMessage = data => { const { color, idDestinoHTML, idOrigenHTML, userOrigen, message } = data; let activeChatID = document.querySelector(`#${idDestinoHTML}`) || document.querySelector(`#${idOrigenHTML}`); if (!activeChatID) { chatTo(data); activeChatID = document.querySelector(`#${idDestinoHTML}`) || document.querySelector(`#${idOrigenHTML}`); } if (idDestinoHTML !== idOrigenHTML) { const div = document.createElement('div'); const span = document.createElement('span'); span.style.color = color; span.classList.add('chatUser'); span.appendChild(document.createTextNode(`${userOrigen}:`)); div.appendChild(span); div.innerHTML += message; activeChatID.appendChild(div); activeChatID.scrollTo(0, activeChatID.scrollHeight); } const newMessage = document.querySelector('.tabs a.active').parentElement.dataset.content if (newMessage !== activeChatID.id && newMessage !== idDestinoHTML) { const destino = document.querySelector(`.tabs li[data-content='${idDestinoHTML}'] i`); const origen = document.querySelector(`.tabs li[data-content='${idOrigenHTML}'] i`); (destino || origen).classList.remove('hide'); } }; /** * @description Añade y actualiza el panel lateral con el listado de los usuarios * @param {object} payload Información de cada uno de los usuarios */ const registerUser = payload => { Array.from(usersPanel.children).forEach(item => item.remove()) payload.forEach(item => { const div = document.createElement('div'); div.style.color = item.color; div.classList.add('chatUser'); div.onclick = () => { chatTo(item); }; div.setAttribute('data-idUser', item.idUser); div.setAttribute('data-idHTML', item.idHTML); div.appendChild(document.createTextNode(item.user)); usersPanel.appendChild(div); if (user.value === item.user) { user.setAttribute('data-idHTML', item.idHTML); user.setAttribute('data-idUser', item.idUser); } }); }; /** * @description Muestra mensaje de error al registrar el usuario * @param {object} payload Información de cada uno de los usuarios * @param {object} evt Evento implicito en la acción ejecutada */ const errorRegisteredUser = (payload, evt) => { const { error } = payload; notify(error, 'danger'); evt.target.classList.remove('disabled'); user.classList.add('has-background-danger'); }; /** * @description Realiza la conexion al servidor * @param {object} evt Evento implicito en la acción ejecutada */ const connectedToServer = evt => { const target = evt.target; if (target.value.length >= LENGTH_MIN_USERNAME) { target.classList.add('disabled'); user.classList.remove('has-background-danger'); socket = io.connect(); socket.on('recive-message', reciveMessage); socket.on('registered-user', registerUser); socket.on('error-registered-user', payload => errorRegisteredUser(payload, evt)); socket.on('client-been-writing', clientBeenWriting); socket.on('upload-progress', data => uploadProgress(data)); const idHTML = generateIDHtml(); socket.emit('connected-to-server', { user: target.value, idHTML }); } else { notify(LITERAL.minSizeUser, 'danger'); } }; /** * @description Mostramos el progreso de la subida * @param {object} data Información sobre la subida del archivo */ const uploadProgress = data => { const { recived, total, who } = data; const porcent = Math.floor((recived * 100) / total); const currentlySize = (recived / 1024) / 1024; // MB const totalSize = (total / 1024) / 1024; // MB const progress = document.querySelector('#upload-progress-bar'); const infoProgress = document.querySelector('.info-progress'); progress.value = Math.floor(porcent); progress.innerHTML = porcent.toFixed(2); infoProgress.innerHTML = `${currentlySize.toFixed(2)} MB ${totalSize.toFixed(2)} MB ${porcent} %`; }; /** * @description Cargamos un fichero y se sube al servidor * @param {object} evt Evento implicito en la acción ejecutada */ const upload = async evt => { if (user.value.length >= LENGTH_MIN_USERNAME) { const uploadProgress = document.querySelector('#containerProgress'); uploadProgress.classList.remove('hidden'); const btnUploadFile = document.querySelector('#btnUploadFile'); btnUploadFile.classList.add('is-loading'); btnUploadFile.setAttribute('disabled', true); const files = evt.target.files; const data = new FormData(); data.append('archivo', files[0]); socket.emit('upload-file', { idUser: user.dataset.iduser }); const result = await (await fetch('/upload-file', { method: 'POST', body: data })).json(); btnUploadFile.classList.remove('is-loading'); btnUploadFile.removeAttribute('disabled'); const { statusCode, path, statusMessage } = result; if (statusCode === 200) { message.value = `${LITERAL.uploadFile} <a href='${statusMessage}' target='_blank'>[${files[0].name}]</a>`; sendMessage(evt = {key: 'Enter'}, true); notify(LITERAL.uploadSuccess, 'success', 4000); } else { notify(statusMessage, 'danger', 4000); } setTimeout(() => { uploadProgress.classList.add('hidden'); }, 2000); } else { document.querySelector('form').reset(); notify(LITERAL.minSizeUser, 'danger'); } }; /** * @description Inicialización del chat */ const load = () => { const tabGeneral = document.querySelector('#tabGeneral'); user = document.querySelector('#user'); message = document.querySelector('#message'); usersPanel = document.querySelector('#userConnected'); notification = document.querySelector('.notification'); message.addEventListener('keydown', evt => sendMessage(evt, true)); message.addEventListener('keyup', evt => sendMessage(evt, false)); user.addEventListener('blur', connectedToServer); tabGeneral.addEventListener('click', selectedChat); uploadFile = document.querySelector('input[type=file]'); uploadFile.addEventListener('change', upload); }; /** * @description Muestra notificaciones en el chat * @param {string} msn Mensaje que se muestra en la notificación * @param {string} type Tipo de mensaje (danger, info, success, warning) * @param {number} timeout Duración de la notificación */ const notify = (msn = '', type = 'info', timeout = 2000) => { notification.children[0].innerHTML = msn; notification.classList.add(`is-${type}`); notification.classList.remove('hidden'); setTimeout(() => { notification.classList.add('hidden'); notification.classList.remove(`is-${type}`); }, timeout); }; /** * @description Generamos un ID para cada usuario */ const generateIDHtml = () => new Date().getTime().toString().split('').map(i => String.fromCharCode(parseInt(i) + 65)).join(''); /** * @description Espera a que este la página completamente cargada */ document.addEventListener("DOMContentLoaded", load);
En un futuro me gustaría realizar esta misma aplicación usando React o Vue, entre otras cosas para aprovechar la magia que ofrecen y poder añadir mas características como, llamadas de audio y video, compartir pantalla, guardar conversaciones, stickers, gifs, etc…
Para finalizar esta entrada, si estás interesado en usar el código, lo puedes obtener desde Github y ademas puedes probar el chat en la siguiente url: https://chat.tecnops.es/
Hola, el chat se ve interesante y me gustaria probarlo =D, pero me marca algunos errores al tratar de ejecutarlo, de casualidad cuales son los comandos previos para poder ejecutar el chat
Hola Carmen, exactamente que errores aparecen?
No funciona la subida de imagenes, manda el error «Error: ENOENT: no such file or directory, open»
Bien, acabo de ver el problema. Se me olvido añadir la carpeta upload-file dentro de la carpeta public y además no estoy realizando la comprobación en node.js.
Como solución rápida si añades la carpeta se soluciona el problema.
No obstante voy actualizar el repositorio con este cambio para futuros usuarios.
al intentar enviar un archivo en el chat, este se traba y se corta la conexción de todos los chats, y en la terminal me sale:
File {
_events: [Object: null prototype] {},
_eventsCount: 0,
_maxListeners: undefined,
size: 0,
path: ‘C:\\Users\\LANIXX\\AppData\\Local\\Temp\\upload_1bde655cda326f605f369f9d673035b0’,
name: ‘Captura de pantalla (1).png’,
type: ‘image/png’,
hash: null,
lastModifiedDate: null,
_writeStream: null,
[Symbol(kCapture)]: false
} archivo
node:events:505
throw er; // Unhandled ‘error’ event
^
Error: ENOENT: no such file or directory, open ‘C:\Users\LANIXX\Downloads\practica alonso\chat-node-socket-js-develop\sockets\public\upload-file\Captura de pantalla (1).png’
Emitted ‘error’ event on WriteStream instance at:
at emitErrorNT (node:internal/streams/destroy:157:8)
at emitErrorCloseNT (node:internal/streams/destroy:122:3)
at processTicksAndRejections (node:internal/process/task_queues:83:21) {
errno: -4058,
code: ‘ENOENT’,
syscall: ‘open’,
path: ‘C:\\Users\\LANIXX\\Downloads\\practica alonso\\chat-node-socket-js-develop\\sockets\\public\\upload-file\\Captura de pantalla (1).png’
}
Solo ejecute el comando de «npm install» y el de correr el programa «node ./sockets/server.js «, sera que me falta ejecutar algun otro comando para que me pueda funcionar la parte de enviar archivos?
Bien, acabo de ver el problema. Se me olvido añadir la carpeta upload-file dentro de la carpeta public y además no estoy realizando la comprobación en node.js.
Como solución rápida si añades la carpeta se soluciona el problema.
No obstante voy actualizar el repositorio con este cambio para futuros usuarios.
Excelente el chat me gusto mucho
Como puedo insertar un boton enviar al formulario?
Veo que solo se puede enviar por el evento keypress ‘Enter’
Hola Paco, gracias por tus comentarios.
Bien, se puede hacer de varias formas pero básicamente es capturar el evento onSubmit y allí dentro haría una llamada al método sendMessage con la configuración necesária,
Si quieres usar el código de este ejemplo, yo haría un método intermedio que gestione los eventos y ese método sería el que hace la llamada al sendMessage.
te quedo genial el chat 🙂
Muchas gracias Brian!!