En este artículo, estaremos usando Socket.io y HarperDB para construir una aplicación chat fullstack de tiempo real con salas de chat.

Este será un gran proyecto para aprender cómo armar las aplicaciones fullstack, y cómo crear una aplicación donde el back-end puede comunicarse con el front-end en tiempo real.

Normalmente, usando solicitudes HTTP, el servidor no puede enviar datos al cliente en tiempo real. Pero al usar Socket.io, el servidor es capaz de enviar información en tiempo real al cliente sobre algunos eventos que sucedieron en el servidor.

La aplicación que estaremos construyendo tiene dos páginas:

Una página join-a-chat-room:

How our app home page will look: a form with username input, select room dropdown and Join Room button

Y una página chat-room:

The finished chat page

Aquí está lo que estaremos usando para construir este aplicación:

  • Front-end: React (Un framework de front-end de JavaScript para construir aplicaciones interactvias)
  • Back-end: Node y Express (Express es un framework muy popular de NodeJS que nos permite crear fácilmente APIs y back-ends)
  • Base de Datos: HarperDB (una plataforma de datos + aplicación que te permite solicitar datos usando SQL o NoSQL. HarperDB también tiene una API integrada, ahorrándonos el escribir mucho código de back-end)
  • Comunicación en Tiempo Real: Socket.io (¡ve abajo!)

Aquí está el código fuente (recuerda de darle una estrella ⭐).

Tabla de Contenidos

  1. ¿Qué es Socket.io?
  2. Configuración del Proyecto
  3. Cómo construir la Página "Join a Room"
  4. Cómo configurar el Servidor
  5. Cómo crear nuestro primer Escucha de Evento de Socket.io en el Servidor
  6. Cómo funcionan las Salas en Socket.io
  7. Cómo construir la Página Chat
  8. Cómo crear el componente Messages (B)
  9. Cómo crear un Esquema y una Tabla en HarperDB
  10. Cómo crear el Componente Send Message (C)
  11. Cómo configurar las Variables de Entorno de HarperDB
  12. Cómo permitir a los Usuarios que se envíen Mensajes con Socket.io
  13. Cómo obtener los Mensajes desde HarperDB
  14. Cómo mostrar los Últimos 100 Mensajes en el Cliente
  15. Cómo mostrar la Sala y los Usuarios (A)
  16. Cómo quitar a un Usuario de una Sala de Socket.io
  17. Cómo agregar el Escucha de Evento Disconnect de Socket.io

¿Qué es Socket.io?

Socket.io permite al servidor enviar información al cliente en tiempo real, cuando los eventos ocurren en el servidor.

Por ejemplo, si estuvieras jugando un juego de múltiple jugador, un evento podría ser tu "amigo" que consiguió un gol espectacular en contra tuya.

Con Socket.io, sabrías (casi) instantáneamente sobre conceder un gol.

Sin Socket.io, el cliente tendría que hacer múltiples llamadas polling de AJAX para verificar que el evento ha ocurrido en el servidor. Por ejemplo, el cliente podría usar JavaScript para verificar un evento en el servidor cada 5 segundos.

Socket.io significa que el cliente no tiene que hacer múltiples llamadas polling de AJAX para verificar si algunos eventos han ocurrido en el servidor. Más bien, el servidor envía la información al cliente tan pronto como lo obtenga. Mucho mejor.👌

Así que, Socket.io nos permite construir fácilmente aplicaciones de tiempo real, tales comos aplicaciones de chat y juegos multijugador.

Configuración del Proyecto

1. Cómo configurar nuestras carpetas

Comienza un nuevo proyecto en tu editor de texto de prefrencia (VS Code para mí), y crea dos carpetas a nivel raíz llamados client y server.

Realtime chat app folder structure

Crearemos nuestra aplicación React Front-end  en la carpeta client, y nuestro backend con Node/express en la carpeta server.

2. Cómo instalar nuestras dependencias de client

Abre una terminal en la raíz del proyecto (en VS Code, puedes hacer esto al presionar Ctrl + ' o al ir a terminal->new terminal).

Luego, instalaremos React en nuestra carpeta client:

$ npx create-react-app client

Después de que React se haya instalado, ve hacia la carpeta client, e instala las siguientes dependencias:

$ cd client
$ npm i react-router-dom socket.io-client

React-router-dom nos permitirá configurar las rutas a nuestros distintos componentes de React – esencialmente creando diferentes páginas.

Socket.io-client es la versión cliente de socket.io, que nos permite "emitir" eventos al servidor. Una vez que el servidor lo recibe, podemos usar la versión del servidor de socket.io para hacer cosas como enviar mensajes a los usuarios en la misma sala como el emisor, o unirse con un usuario a una sala de socket.

Ganarás un mejor entendimiento de esto más tarde cuando lleguemos a implementar estas ideas con código.

3. Cómo iniciar la aplicación de React

Vamos a asegurarnos que todo está funcionando bien ejecutando el siguiente comando desde la carpeta client:

$ npm start

Webpack construirá la aplicación de React y lo servirá en `http://localhost:3000`:

Create react app up and running on localhost

Ahora configuremos nuestra base de datos de HarperDB que usaremos para guardar mensajes de forma permanente enviados por los usuarios.

Cómo configurar HarperDB

Primero, crea una cuenta con HarperDB.

Luego crea una nueva instancia en la nube de HarperDB.

create HarperDB instance

Para hacer las cosas fáciles, selecciona la instancia cloud:

select HarperDB instance type

Selecciona el proveedor del cloud (Yo escojo AWS):

select HarperDB cloud provider

Brinda un nombre a tu instancia de la nube, y crea tus credenciales de la instancia:

select HarperDB instance credentials

HarperDB tiene una capa gratuita generosa que podemos usar para este proyecto, así que selecciona ese:

select HarperDB instance specs

Verifica que tus detalles son correctos, luego crea la instancia.

Tomará unos pocos minutos para crear la instancia, así que continuemos y, ¡hagamos nuestro primer componente de React!

HarperDB instance loading

Cómo construir la Página "Join a Room"

Nuestra página principal va a terminar luciendo así:

How our app home page will look: a form with username input, select room dropdown and Join Room button

El usuario ingresará un nombre de usuario, selecciona una sala de chat desde el despegable, luego haz clic en "Join Room". El usuario será dirigido a la página de la sala de chat.

Así que, hagamos esta página principal.

1. Cómo crear el formulario HTML y agregar estilos

Crea un nuevo archivo en src/pages/home/index.js.

Agregaremos estilos básicos a nuestra aplicación usando módulos de CSS, así que crea un nuevo archivo: src/pages/home/styles.module.css.

Nuestra estructura de carpeta debería ahora lucir así:

pages folder with home page component

Ahora creemos el formulario básico HTML:

// client/src/pages/home/index.js

import styles from './styles.module.css';

const Home = () => {
  return (
    <div className={styles.container}>
      <div className={styles.formContainer}>
        <h1>{`<>DevRooms</>`}</h1>
        <input className={styles.input} placeholder='Username...' />

        <select className={styles.input}>
          <option>-- Select Room --</option>
          <option value='javascript'>JavaScript</option>
          <option value='node'>Node</option>
          <option value='express'>Express</option>
          <option value='react'>React</option>
        </select>

        <button className='btn btn-secondary'>Join Room</button>
      </div>
    </div>
  );
};

export default Home;

Arriba, tenemos un input de texto sencillo para capturar el nombre de usuario, y un despegable seleccionable con algunas opciones por defecto para que el usuario seleccione una sala de chat para unirse.

Ahora importemos este componente en App.js y configuremos una ruta para el componente usando el paquete react-router-dom. Este será nuestra página principal, así que la ruta será /:

// client/src/App.js

import './App.css';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './pages/home';

function App() {
  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route path='/' element={<Home />} />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Ahora agreguemos algunos estilos básicos para hacer que nuestra aplicación luzca mas presentable:

/* client/src/App.css */

html * {
  font-family: Arial;
  box-sizing: border-box;
}
body {
  margin: 0;
  padding: 0;
  overflow: hidden;
  background: rgb(63, 73, 204);
}
::-webkit-scrollbar {
  width: 20px;
}
::-webkit-scrollbar-track {
  background-color: transparent;
}
::-webkit-scrollbar-thumb {
  background-color: #d6dee1;
  border-radius: 20px;
  border: 6px solid transparent;
  background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
  background-color: #a8bbbf;
}
.btn {
  padding: 14px 14px;
  border-radius: 6px;
  font-weight: bold;
  font-size: 1.1rem;
  cursor: pointer;
  border: none;
}
.btn-outline {
  color: rgb(153, 217, 234);
  border: 1px solid rgb(153, 217, 234);
  background: rgb(63, 73, 204);
}
.btn-primary {
  background: rgb(153, 217, 234);
  color: rgb(0, 24, 111);
}
.btn-secondary {
  background: rgb(0, 24, 111);
  color: #fff;
}

También agreguemos los estilos específicos a nuestro componente de la página principal:

/* client/src/pages/home/styles.module.css */

.container {
  height: 100vh;
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: rgb(63, 73, 204);
}
.formContainer {
  width: 400px;
  margin: 0 auto 0 auto;
  padding: 32px;
  background: lightblue;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 28px;
}
.input {
  width: 100%;
  padding: 12px;
  border-radius: 6px;
  border: 1px solid rgb(63, 73, 204);
  font-size: 0.9rem;
}
.input option {
  margin-top: 20px;
}

También hagamos que el botón "Join Room" tenga el ancho completo agregando un atributo de estilo:

// client/src/pages/home/index.js

<button className='btn btn-secondary' style={{ width: '100%' }}>Join Room</button>

Nuestra página principal ahora se ve sólido:

Fully-styled home page

2. Cómo agregar funcionalidad al formulario Join Room

Ahora tenemos un formulario básico y estilos, así que es tiempo de agregar algo de funcionalidad.

Esto es lo que queremos que suceda cuando el usuario haga clic en el botón "Join Room":

  1. Verificar que el nombre de usuario y los campos de la sala sean rellenados.
  2. Si es así, emitimos un evento de socket a nuestro servidor.
  3. Redireccionar al usuario a la página Chat (el cual crearemos luego).

Vamos a necesitar crear algo de estado para almacenar los valores nombre de usuario y room. También necesitamos crear una instancia del socket.

Podríamos crear estos estados directamente dentro de nuestro componente home, pero nuestra página Chat también necesitará acceder al username, room y socket. Así que elevaremos el estado al App.js, donde podemos pasar estas variables a las páginas HomePage y Chat.

Así que, creemos nuestro estado y configuremos un socket en App.js, y pasemos estas variables como props al componente. También pasaremos la función setState de forma que podamos alterar el estado:

// client/src/App.js

import './App.css';
import { useState } from 'react'; // Add this
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client'; // Add this
import Home from './pages/home';

const socket = io.connect('http://localhost:4000'); // Add this -- our server will run on port 4000, so we connect to it from here

function App() {
  const [username, setUsername] = useState(''); // Add this
  const [room, setRoom] = useState(''); // Add this

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username} // Add this
                setUsername={setUsername} // Add this
                room={room} // Add this
                setRoom={setRoom} // Add this
                socket={socket} // Add this
              />
            }
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Ahora podemos acceder a estos props en nuestro componente Home. Usaremos la destructuración para obtener estas props:

// client/src/pages/home/index.js

import styles from './style.module.css';

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    // ...
  );
};

export default Home;

Cuando el usuario ingrese su nombre de usuario o seleccione una sala, necesitamos actualizar las variables de estado username y room:

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  return (
    <div className={styles.container}>
      // ...
        <input
          className={styles.input}
          placeholder='Username...'
          onChange={(e) => setUsername(e.target.value)} // Add this
        />

        <select
          className={styles.input}
          onChange={(e) => setRoom(e.target.value)} // Add this
        >
         // ...
        </select>

        // ...
    </div>
  );
};

export default Home;

Ahora estamos capturamos los datos ingresados por el usuario, podemos crear una función callback joinRoom() para cuando el usuario haga clic en el botón "Join Room":

// client/src/pages/home/index.js

// ...

const Home = ({ username, setUsername, room, setRoom, socket }) => {

  // Add this
  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }
  };

  return (
    <div className={styles.container}>
      // ...

        <button
          className='btn btn-secondary'
          style={{ width: '100%' }}
          onClick={joinRoom} // Add this
        >
          Join Room
        </button>
      // ...
    </div>
  );
};

export default Home;

Arriba, cuando el usuario haga clic en el botón, un evento del socket llamado joinroom se emite, juntamente con un objeto conteniendo el nombre de usuario y la sala seleccionada. Este evento será recibido por nuestro servidor un poco más tarde cuando haremos algo de magia.

Para terminar nuestra página principal, necesitamos agregar una redirección al final de nuestra función joinRoom() para llevar al usuario a la página /chat:

// client/src/pages/home/index.js

// ...
import { useNavigate } from 'react-router-dom'; // Agrega esto

const Home = ({ username, setUsername, room, setRoom, socket }) => {
  const navigate = useNavigate(); // Agrega esto

  const joinRoom = () => {
    if (room !== '' && username !== '') {
      socket.emit('join_room', { username, room });
    }

    // Redirecciona a /chat
    navigate('/chat', { replace: true }); // Agrega esto
  };

 // ...

Pruébalo: escribe un nombre de usuario y selecciona una sala, luego haz clic en Join Room. Deberías ser dirigido a la ruta http://localhost:3000/chat – actualmente una página vacía.

Pero antes que creemos nuestra Página Chat, tengamos algo ejecutándose en el servidor.

Cómo configurar el Servidor

En el servidor, vamos a escuchar los eventos del socket que son emitidos desde el frontend. Actualmente, solamente tenemos un evento join_room siendo emitido desde React, así que agregaremos este escucha de evento primero.

Pero antes de eso, necesitamos instalar nuestras dependencias del servidor y tener el servidor activo y ejecutándose.

1. Cómo instalar las dependencias del servidor

Abre una nueva terminal (en VS Code: Terminal->Nueva Terminal), cambia la carpeta a nuestra carpeta de servidor, inicializa un archivo package.json, e instala las siguientes dependencias:

$ cd server
$ npm init -y
$ npm i axios cors express socket.io dotenv
  • Axios es un paquete usado comunmente para hacer solicitudes fácilmente a APIs.
  • Cors permite a nuestro cliente hacer solicitudes a otros orígenes – necesario para socket.io para que funcione de forma apropiada. Ve ¿Qué es el CORS? si no has escuchado de CORS antes.
  • Express es un framework de Node.js que nos permite escribir nuestro backend más fácilmente con menos código.
  • Socket.io es una librería que permite que el cliente y el servidor se comunique a tiempo real – el cual no es posible con solicitudes estándares de HTTP.
  • Dotenv es un módulo que nos permite almacenar claves privadas y contraseñas de forma segura, y cargarlos en nuestro códio cuando sea necesario.

También instalaremos nodemon como una dependencia dev, así no tenemos que reiniciar nuestro servidor cada vez que hagamos un cambio al código – ahorrándonos tiempo y energía:

$ npm i -D nodemon

2. Cómo iniciar nuestro servidor

Crea un archivo index.js en la raíz de nuestra carpeta server, y agrega el siguiente código para iniciar el servidor:

// server/index.js

const express = require('express');
const app = express();
const http = require('http');
const cors = require('cors');

app.use(cors()); // Add cors middleware

const server = http.createServer(app);

server.listen(4000, () => 'Server is running on port 4000');

Abre el archivo package.json en nuestro servidor, y agrega un script que nos permitirá usar nodemon en desarrollo:

{
  ...
  "scripts": {
    "dev": "nodemon index.js"
  },
  ...
}

Ahora, iniciemos nuestro servidor al ejecutar el siguiente comando:

$ npm run dev

Podemos verificar rápidamente que nuestro servidor se está ejecutando correctamente al agregar un manejador de solicitudes get:

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');

app.use(cors()); // Agrega cors middleware

const server = http.createServer(app);

// Agrega esto
app.get('/', (req, res) => {
  res.send('Hello world');
});

server.listen(4000, () => 'El servidor está ejecutándose en el puerto 3000');

Ahora ve a http://localhost:4000/:

Image

Nuestro servidor está activo y en ejecución. ¡Ahora es tiempo de hacer cosas de Socket.io en el servidor!

Cómo crear nuestro primer Escucha de Evento de Socket.io en el Servidor

Recuerda que cuando emitimos un evento joinroom desde el cliente? Bueno, pronto vamos a estar escuchar ese evento en el servidor y agregar el usuario a una sala del socket.

Pero primero, necesitamos escuchar cuando un cliente se conecte al servidor a través de socket.io-client.

// server/index.js

const express = require('express');
const app = express();
http = require('http');
const cors = require('cors');
const { Server } = require('socket.io'); // Agrega esto

app.use(cors()); // Agrega cors middleware

const server = http.createServer(app); // Agrega esto

// Agrega esto
// Crea un servidor io y permite CORS desde http://localhost:3000 con metodos GET y POST
const io = new Server(server, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
});

// Agrega esto
// Escucha cuando el cliente se conecta a traves de socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Podemos escribir nuestro our socket event listeners in here...
});

server.listen(4000, () => 'El servidor está ejecutándose en el puerto 3000');

Ahora, cuando el cliente se conecta desde el frontend, el backend captura el evento de la conexión, y registrará User connected con el id único del socket para ese cliente particular.

Probemos si el servidor ahora está captrando el evento de la conección desde el cliente. Ve a tu aplicación de React en http://localhost:3000/ y refresca la página.

Deberías ver el siguiente registro en tu consola de terminal del servidor:

Image

Genial, nuestro cliente se ha conectado a nuestro servidor a través de socket.io. Nuestro cliente y servidor ahora se pueden comunicar en, ¡tiempo real!

Cómo funcionan las Salas en Socket.io

Desde la documentación de Socket.io (no está disponible en español todavía):

"Una sala es un canal arbitrario al que sockets se puede unir (join) y abandonar (leave). Puede ser usado para emitir eventos a un subconjunto de clientes."

Así que, podemos unir el usuario a una sala, y luego el servidor puede enviar mensajes a todos los usuarios en esa sala – permitiendo a los usuarios enviarse mensajes en tiempo real. ¡Genial!

Cómo unir el usuario a una sala de Socket.io

Una vez que el usuario se ha conectado a través de Socket.io, podemos agregar nuestros escuchas de eventos del socket en el servidor para escuchar eventos emitidos desde el cliente. También, podemos emitir eventos en el servidor, y esucharlos en el cliente.

Ahora escuchemos por el evento joinroom, que capture los datos (nombre de usuario y sala), y agregue al usuario a una sala de socket:

// server/index.js

// Escuchar cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Agrega esto
  // Agrega un usuario a una sala
  socket.on('join_room', (data) => {
    const { username, room } = data; // Datos enviados desde el cliente cuando el evento join_room se emite
    socket.join(room); // Une el usuario a una sala de socket
  });
});

Cómo enviar un mensaje a los usuarios en una sala

Ahora enviemos un mensaje a todos los usuarios en la sala, aparte del usuario que se acaba de unir, para notificarlos que un nuevo usuario se ha unido:

// server/index.js

const CHAT_BOT = 'ChatBot'; // Agrega esto
// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Agrega un usuario a una sala
  socket.on('join_room', (data) => {
    const { username, room } = data; // Datos enviados desde el cliente cuando el evento join_room se emite
    socket.join(room); // Une el usuario a una sala de socket

    // Agrega esto
    let __createdtime__ = Date.now(); // Marcas de tiempo actuales
    // Envía un mensaje a todos los usuario en la sala, aparte del usuario que se acaba de unir
    socket.to(room).emit('receive_message', {
      message: `${username} has joined the chat room`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

Arriba, estamos emitiendo un evento receive_message a todos los clientes en la sala en el que el usuario se acaba de unir, juntamente con algo de dato: el mensaje, el nombre de usuario quien envió el mensaje, y el tiempo en que el mensaje fue enviado.

Agregaremos un escucha de evento en nuestra aplicación de React un poco después para capturar este evento, y muestra el mensaje en la pantalla.

También enviemos un mensaje de bienvenida al nuevo usuario recién unido:

// server/index.js

io.on('connection', (socket) => {
  // ...

    // Agrega esto
    // Envía el msg de bienvenida al usuario que se acaba de unir
    socket.emit('receive_message', {
      message: `Welcome ${username}`,
      username: CHAT_BOT,
      __createdtime__,
    });
  });
});

Cuando agregamos un usuario a una sala de Socket.io, Socket.io solamente almacena los ids de los sockets para cada usuario. Pero necesitaremos los nombres de usuario de todos en la sala, así también como el nombre de sala. Así que, almacenemos esos datos en variables en el servidor:

// server/index.js

// ...

const CHAT_BOT = 'ChatBot';
// Agrega esto
let chatRoom = ''; // Ej. javascript, node,...
let allUsers = []; // Todos los usuarios en la sala de chat actual

// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {
    // ...

    // Agrega esto
    // Guarda el nuevo usuario a la sala
    chatRoom = room;
    allUsers.push({ id: socket.id, username, room });
    chatRoomUsers = allUsers.filter((user) => user.room === room);
    socket.to(room).emit('chatroom_users', chatRoomUsers);
    socket.emit('chatroom_users', chatRoomUsers);
  });
});

Arriba, también estamos enviando un arreglo de todo el chatRoomUsers de nuevo al cliente a través del evento chatroomusers, así que podemos listar todos los nombres de usuario en el frontend.

Antes que agreguemos cualquier otro código a nuestro servidor, volvamos a nuestro frontend y creamos la página Chat – así podemos probar si estamos recibiendo los eventos receivemessage.

Cómo construir la Página Chat

En tu carpeta cliente, crea dos nuevos archivos:

  1. src/pages/chat/index.js
  2. src/pages/chat/styles.module.css

Agreguemos algunos estilos que usaremos en nuestra página Chat y componentes:

/* client/src/pages/chat/styles.module.css */

.chatContainer {
  max-width: 1100px;
  margin: 0 auto;
  display: grid;
  grid-template-columns: 1fr 4fr;
  gap: 20px;
}

/* Room and users component */
.roomAndUsersColumn {
  border-right: 1px solid #dfdfdf;
}
.roomTitle {
  margin-bottom: 60px;
  text-transform: uppercase;
  font-size: 2rem;
  color: #fff;
}
.usersTitle {
  font-size: 1.2rem;
  color: #fff;
}
.usersList {
  list-style-type: none;
  padding-left: 0;
  margin-bottom: 60px;
  color: rgb(153, 217, 234);
}
.usersList li {
  margin-bottom: 12px;
}

/* Messages */
.messagesColumn {
  height: 85vh;
  overflow: auto;
  padding: 10px 10px 10px 40px;
}
.message {
  background: rgb(0, 24, 111);
  border-radius: 6px;
  margin-bottom: 24px;
  max-width: 600px;
  padding: 12px;
}
.msgMeta {
  color: rgb(153, 217, 234);
  font-size: 0.75rem;
}
.msgText {
  color: #fff;
}

/* Message input and button */
.sendMessageContainer {
  padding: 16px 20px 20px 16px;
}
.messageInput {
  padding: 14px;
  margin-right: 16px;
  width: 60%;
  border-radius: 6px;
  border: 1px solid rgb(153, 217, 234);
  font-size: 0.9rem;
}

Ahora, veamos cómo terminará luciendo nuestra página Chat:

The finished chat page

Agregando todo el código y lógica para esta página en un archivo podría volverse confuso y difícil de manejar, así que aprovechemos el hecho de que estamos usando un framework de frontend fabuloso (React) y dividir nuestra página en componentes:

The chat page split into three components

Los componentes de la página chat:

A: Contiene el nombre de la sala, una lista de usuarios en esa sala, y un botón "Leave" que quitar al usuario desde la sala.

B: Los mensajes enviados. Al inicio de la renderización, los últimos 100 mensajes enviados en esa sala será solicitado desde la base de datos y mostrado al usuario.

C: Una entrada y botón para escribir y enviar un mensaje.

Primero crearemos el componente B, así podemos mostrar los mensajes al usuario.

Cómo crear el Componente Messages (B)

Crea un nuevo archivo en src/pages/chat/messages.js y agrega el siguiente código:

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  // Se ejecuta cuando sea que un evento del socket es recibido desde el servidor
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

    // Quita la escucha del evento al desmontar el componente
    return () => socket.off('receive_message');
  }, [socket]);

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    <div className={styles.messagesColumn}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

Ahora, tenemos un hook useEffect que se ejecuta cuando sea que el evento del socket se recibe. Luego recibimos los datos del mensaje en el escucha del evento receivemessage. Desde ahí, ponemos el estado messagesReceived, el cual es un arreglo de objetos de mensaje conteniendo el mensaje, nombre de usuario del emisor, y la fecha del mensaje que fue enviado.

Importemos nuestro nuevo componentes de mensajes en la página Chat, y luego creemos una ruta para la página Chat en App.js:

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';

const Chat = ({ socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
      </div>
    </div>
  );
};

export default Chat;
// client/src/App.js

import './App.css';
import { useState } from 'react';
import Home from './pages/home';
import Chat from './pages/chat';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import io from 'socket.io-client';

const socket = io.connect('http://localhost:4000');

function App() {
  const [username, setUsername] = useState('');
  const [room, setRoom] = useState('');

  return (
    <Router>
      <div className='App'>
        <Routes>
          <Route
            path='/'
            element={
              <Home
                username={username}
                setUsername={setUsername}
                room={room}
                setRoom={setRoom}
                socket={socket}
              />
            }
          />
          {/* Add this */}
          <Route
            path='/chat'
            element={<Chat username={username} room={room} socket={socket} />}
          />
        </Routes>
      </div>
    </Router>
  );
}

export default App;

Probemos esto, ve a la página principal y únete a una sala:

Joining a room as Dan

Deberíamos ser llevados a la página Chat, y recibir un mensaje de bienvenida del ChatBot:

Welcome message received from ChatBot

Los usuarios ahora pueden ver los mensajes que reciben. ¡Genial!

Próximo: configurar nuestra base de datos así podemos guardar los mensajes permanentemente.

Cómo crear un Esquema y una Tabla en HarperDB

Vuelve a tu dashboard de HarperDB, y haz clic en "browser". Luego crea un nuevo esquemos llamado "realtime_chat_app". Un esquema es simplemente un grupo de tablas.

Dentro de ese esquema, crea una tabla llamada "messages", con un atributo hash de "id".

Creating our schema and table in HarperDB

Ahora tenemos necestiamos donde almacenar los mensajes, así que creemos el componente SendMessage.

Cómo crear el componente Send Message (C)

Crea el archivo src/pages/chat/send-message.js y agrega el siguiente código:

// client/src/pages/chat/send-message.js

import styles from './styles.module.css';
import React, { useState } from 'react';

const SendMessage = ({ socket, username, room }) => {
  const [message, setMessage] = useState('');

  const sendMessage = () => {
    if (message !== '') {
      const __createdtime__ = Date.now();
      // Envia un mensaje al servidor. No podemos especificar a quien enviamos el mensaje desde el frontend. Solamente podemos enviar al servidor. El servidor por lo tanto puede enviar un mensaje al resto de los usuarios en la sala
      socket.emit('send_message', { username, room, message, __createdtime__ });
      setMessage('');
    }
  };

  return (
    <div className={styles.sendMessageContainer}>
      <input
        className={styles.messageInput}
        placeholder='Message...'
        onChange={(e) => setMessage(e.target.value)}
        value={message}
      />
      <button className='btn btn-primary' onClick={sendMessage}>
        Send Message
      </button>
    </div>
  );
};

export default SendMessage;

Arriba, cuando el usuario hace clic en el botón "Send Message", un evento del socket send_message se emite al servidor, junto con un objeto de mensaje. Manejaremos este evento en el servidor dentro de poco.

Importa SendMessage en nuestra página Chat:

// src/pages/chat/index.js

import styles from './styles.module.css';
import MessagesReceived from './messages';
import SendMessage from './send-message';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

La página Chat ahora luce así:

Chat page now has a message input where a message can be typed and sent

Luego necesitamos configurar nuestras variables de entorno de HarperDB así podemos comenzar a interactuar con la base de datos.

Cómo configurar las Variables de Entorno de HarperDB

Para que seas capaz de guardar los mensajes en HarperDB, necesitarás tu URL de instancia de HarperDB, y tu contraseña API.

En tu dashboard de HarperDB, haz clic en tu instancia, luego ve a "config". Encontrarás tu URL de instancia, y la cabecera de Autenticación de API de tu instancia – eso es, tu contraseña "super_user" que te permite realizar cualquier solicitud a la base de datos – ¡PARA TUS OJOS SOLAMENTE!

HarperDB instance URL and API auth header

Guardaremos estas variables en un archivo .env. Advertencia: ¡No empujes el archivo .env a Github! Este archivo no debería ser visible públicamente. Las variables se cargan a través del servidor por detrás.

Crea los siguientes archivos y agrega tu URL y contraseña de HarperDB:

// server/.env

HARPERDB_URL="<tu url va aqui>"
HARPERDB_PW="Basic <tu contraseña aqui>"

También crearemos un archivo .gitignore para evitar que el .env se suba a Github, juntamente con la carpeta node_modules:

// server/.gitignore

.env
node_modules

Nota: ser bueno en Git y Github es 100% imprescindible para todos los desarrolladores. Revisa mi artículo de los flujos de trabajo de Git si necesitas mejorar tu juego de Git.

O si constantemente te encuentras revisando los mismos comandos de Git, y quieres una forma rápida de verlos, y copiar/pegar los comandos – revisa mi hoja de trucos de los comandos de Git en PDF y mi póster de hoja de trucos de Git físico.

Finalmente, carguemos nuestras variables de entorno en nuestro servidor al agregar este código en la parte superior de nuestro archivo main:

// server/index.js

require('dotenv').config();
console.log(process.env.HARPERDB_URL); // quita esto despues que hayas visto que funcionó
const express = require('express');
// ...

Cómo permitir a los Usuarios que se envíen mensajes con Socket.io

En el servidor, escucharemos el evento sendmessage, luego enviaremos el mensaje a todos los usuarios dentro de la sala:

// server/index.js

const express = require('express');
// ...
const harperSaveMessage = require('./services/harper-save-message'); // Agrega esto

// ...

// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {

  // ...

  // Agrega esto
  socket.on('send_message', (data) => {
    const { message, username, room, __createdtime__ } = data;
    io.in(room).emit('receive_message', data); // Envia a todos los usuarios en la sala, incluyendo al emisor
    harperSaveMessage(message, username, room, __createdtime__) // Guarda el mensaje en la bd
      .then((response) => console.log(response))
      .catch((err) => console.log(err));
  });
});

server.listen(4000, () => 'El Servidor se está ejecutando en el puerto 3000');

Ahora necesitamos crear la función harperSaveMessage. Crea un nuevo archivo en server/services/harper-save-message.js, y agrega lo siguiente:

// server/services/harper-save-message.js

var axios = require('axios');

function harperSaveMessage(message, username, room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  var data = JSON.stringify({
    operation: 'insert',
    schema: 'realtime_chat_app',
    table: 'messages',
    records: [
      {
        message,
        username,
        room,
      },
    ],
  });

  var config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperSaveMessage;

Arriba, guardar los datos podria tomar un poco de tiempo, así que devolvemos una promesa el cual será resuelto si los datos se guardan satisfactoriamente, o rechazada si no se guardan.

Si te estás preguntando donde obtuve el código de arriba, HarperDB provee una sección increíble de "ejemplos de código" en su dashboard studio, el cual nos facilita la vida:

HarperDB code examples

¡Tiempo de descanso! Únete a una sala como un usuario, luego envía un mensaje. Luego ve a HarperDB y haz clic en "browse", luego haz clic en la tabla "messages". Deberías ver tú mensaje en la base de datos:

Our first messages in the database

Genial 😎. Así que, ¿ahora qué? Bueno, sería bueno si los últimos 100 mensajes enviados en la sala fueran cargados cuando un usuario se una a  una sala, ¿no?

Cómo obtener los mensajes desde HarperDB

En el servidor, creemos una función que solicite los últimos 100 mensajes enviados en una sala particular (fíjate cómo HarperDB también nos permite usar solicitudes de SQL 👌):

// server/services/harper-get-messages.js

let axios = require('axios');

function harperGetMessages(room) {
  const dbUrl = process.env.HARPERDB_URL;
  const dbPw = process.env.HARPERDB_PW;
  if (!dbUrl || !dbPw) return null;

  let data = JSON.stringify({
    operation: 'sql',
    sql: `SELECT * FROM realtime_chat_app.messages WHERE room = '${room}' LIMIT 100`,
  });

  let config = {
    method: 'post',
    url: dbUrl,
    headers: {
      'Content-Type': 'application/json',
      Authorization: dbPw,
    },
    data: data,
  };

  return new Promise((resolve, reject) => {
    axios(config)
      .then(function (response) {
        resolve(JSON.stringify(response.data));
      })
      .catch(function (error) {
        reject(error);
      });
  });
}

module.exports = harperGetMessages;

Llamaremos a esta función cuando sea que un usuario se una a una sala:

// server/index.js

// ...
const harperSaveMessage = require('./services/harper-save-message');
const harperGetMessages = require('./services/harper-get-messages'); // Add this

// ...

// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {
  console.log(`User connected ${socket.id}`);

  // Agrega un usuario a una sala
  socket.on('join_room', (data) => {

    // ...

    // Agrega esto
    // Obtiene los últimos 100 mensajes enviados en la sala chat
    harperGetMessages(room)
      .then((last100Messages) => {
        // console.log('ultimos mensajes', last100Messages);
        socket.emit('last_100_messages', last100Messages);
      })
      .catch((err) => console.log(err));
  });

 // ...

Arriba, si los mensajes se solicitan de forma exitosa, emitimos un evento de Socket.io llamado last_100_messages. Ahora escucharemos este evento en el frontend.

Cómo mostrar los últimos 100 mensajes en el Cliente

Abajo, agregamos un hook useEffect que contiene un escucha de evento de Socket.io para el evento last_100_messages. Desde ahí, los mensajes son ordenados en orden de fecha, con el más reciente al final, y el estado messagesReceived se actualiza.

Cuando messagesReceived se actualiza, un useEffect se ejecuta para desplazar el div messageColumn al mensaje más reciente. Esto mejora la experiencia de usuario de nuestra aplicación 👍.

// client/src/pages/chat/messages.js

import styles from './styles.module.css';
import { useState, useEffect, useRef } from 'react';

const Messages = ({ socket }) => {
  const [messagesRecieved, setMessagesReceived] = useState([]);

  const messagesColumnRef = useRef(null); // Add this

  // Se ejecuta cuando sea que un evento del socket se reciba desde el servidor
  useEffect(() => {
    socket.on('receive_message', (data) => {
      console.log(data);
      setMessagesReceived((state) => [
        ...state,
        {
          message: data.message,
          username: data.username,
          __createdtime__: data.__createdtime__,
        },
      ]);
    });

    // Quita el escucha de eventos al desmontarse el componente 
    return () => socket.off('receive_message');
  }, [socket]);

  // Agrega esto
  useEffect(() => {
    // Ultimos 100 mensajes enviados en la sala chat (solicitados desde la bd en el backend)
    socket.on('last_100_messages', (last100Messages) => {
      console.log('Last 100 messages:', JSON.parse(last100Messages));
      last100Messages = JSON.parse(last100Messages);
      // Ordena estos mensajes por __createdtime__
      last100Messages = sortMessagesByDate(last100Messages);
      setMessagesReceived((state) => [...last100Messages, ...state]);
    });

    return () => socket.off('last_100_messages');
  }, [socket]);

  // Agrega esto
  // Se desplaza al mensaje más reciente
  useEffect(() => {
    messagesColumnRef.current.scrollTop =
      messagesColumnRef.current.scrollHeight;
  }, [messagesRecieved]);

  // Agrega esto
  function sortMessagesByDate(messages) {
    return messages.sort(
      (a, b) => parseInt(a.__createdtime__) - parseInt(b.__createdtime__)
    );
  }

  // dd/mm/yyyy, hh:mm:ss
  function formatDateFromTimestamp(timestamp) {
    const date = new Date(timestamp);
    return date.toLocaleString();
  }

  return (
    // Agrega ref a este div
    <div className={styles.messagesColumn} ref={messagesColumnRef}>
      {messagesRecieved.map((msg, i) => (
        <div className={styles.message} key={i}>
          <div style={{ display: 'flex', justifyContent: 'space-between' }}>
            <span className={styles.msgMeta}>{msg.username}</span>
            <span className={styles.msgMeta}>
              {formatDateFromTimestamp(msg.__createdtime__)}
            </span>
          </div>
          <p className={styles.msgText}>{msg.message}</p>
          <br />
        </div>
      ))}
    </div>
  );
};

export default Messages;

Cómo mostrar la Sala y los Usuarios (A)

Hemos hecho los componentes B y C, así que terminemos todo al hacer A.

The chat page split into three components

En el servidor, cuando un usario se une a una sala, emitimos un evento chatroomusers que envía todos los usuarios de la sala a todos los clientes en esa sala. Escuchemos ese evento en un componente llamado RoomAndUsers.

Abajo, también hay un botón "Leave" que, cuando se presiona, hace la emisión de un evento leaveroom al servidor. Luego redirecciona el usuario devuelta a la página principal.

// client/src/pages/chat/room-and-users.js

import styles from './styles.module.css';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

const RoomAndUsers = ({ socket, username, room }) => {
  const [roomUsers, setRoomUsers] = useState([]);

  const navigate = useNavigate();

  useEffect(() => {
    socket.on('chatroom_users', (data) => {
      console.log(data);
      setRoomUsers(data);
    });

    return () => socket.off('chatroom_users');
  }, [socket]);

  const leaveRoom = () => {
    const __createdtime__ = Date.now();
    socket.emit('leave_room', { username, room, __createdtime__ });
    // Redirecciona a la pagina principal
    navigate('/', { replace: true });
  };

  return (
    <div className={styles.roomAndUsersColumn}>
      <h2 className={styles.roomTitle}>{room}</h2>

      <div>
        {roomUsers.length > 0 && <h5 className={styles.usersTitle}>Users:</h5>}
        <ul className={styles.usersList}>
          {roomUsers.map((user) => (
            <li
              style={{
                fontWeight: `${user.username === username ? 'bold' : 'normal'}`,
              }}
              key={user.id}
            >
              {user.username}
            </li>
          ))}
        </ul>
      </div>

      <button className='btn btn-outline' onClick={leaveRoom}>
        Leave
      </button>
    </div>
  );
};

export default RoomAndUsers;

Importemos este componente en la página Chat:

// client/src/pages/chat/index.js

import styles from './styles.module.css';
import RoomAndUsersColumn from './room-and-users'; // Add this
import SendMessage from './send-message';
import MessagesReceived from './messages';

const Chat = ({ username, room, socket }) => {
  return (
    <div className={styles.chatContainer}>
      {/* Agrega esto */}
      <RoomAndUsersColumn socket={socket} username={username} room={room} />

      <div>
        <MessagesReceived socket={socket} />
        <SendMessage socket={socket} username={username} room={room} />
      </div>
    </div>
  );
};

export default Chat;

Cómo quitar a un Usuarío de una sala de Socket.io

Socket.io provee un método leave() que puedes usar para quitar a un usuario de una sala de Socket.io. También estamos manteniendo un registro de nuestros usuarios en un arreglo en la memoria del servidor, así que quitaremos al usuario de ese arreglo también:

// server/index.js

const leaveRoom = require('./utils/leave-room'); // Add this

// ...

// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {

  // ...

  // Agrega esto
  socket.on('leave_room', (data) => {
    const { username, room } = data;
    socket.leave(room);
    const __createdtime__ = Date.now();
    // Quita a un usuario de la memoria
    allUsers = leaveRoom(socket.id, allUsers);
    socket.to(room).emit('chatroom_users', allUsers);
    socket.to(room).emit('receive_message', {
      username: CHAT_BOT,
      message: `${username} has left the chat`,
      __createdtime__,
    });
    console.log(`${username} has left the chat`);
  });
});

server.listen(4000, () => 'El Servidor se está ejecutando en el puerto 3000');

Ahora necesitamos crear la función leaveRoom():

// server/utils/leave-room.js

function leaveRoom(userID, chatRoomUsers) {
  return chatRoomUsers.filter((user) => user.id != userID);
}

module.exports = leaveRoom;

¿Por qué poner esta función corta en una carpeta utils aparte, te preguntas? Porque lo estaremos usando luego y no queremos repetirnos (mantenemos nuestro código DRY).

Probémoslo, abre dos ventanas lado a lado, y únete a ambos chats:

Two windows chatting in realtime.

Luego haz clic en el botón leave en la ventana 2:

The user is removed from the chat when they click the Leave button

El usuario se quita del chat, y un mensaje se envía a los otros usuarios – notificándolos que se ha ido. ¡Bien!

Cómo agregar el Escucha de Evento Disconnect de Socket.io

¿Qué pasa si el usuario de alguna manera se desconecta del servidor, como si su internet se cayera? Socket.io provee un escucha de evento adjunto disconnects para esto. Agreguémosle a nuestro servidor para quitar a un usuario de la memoria cuando se desconecta:

// server/index.js

// ...

// Escucha cuando el cliente se conecta a través de socket.io-client
io.on('connection', (socket) => {

  // ...

  // Agrega esto
  socket.on('disconnect', () => {
    console.log('El usuario se desconectó del chat');
    const user = allUsers.find((user) => user.id == socket.id);
    if (user?.username) {
      allUsers = leaveRoom(socket.id, allUsers);
      socket.to(chatRoom).emit('chatroom_users', allUsers);
      socket.to(chatRoom).emit('receive_message', {
        message: `${user.username} se ha desconectado del chat.`,
      });
    }
  });
});

server.listen(4000, () => 'El Servidor se está ejecutando en el puerto 3000');

Y ahí lo tienes – haz construido una aplicación de chat de tiempo real fullstack con un frontend de React, un backend de Node/Express, y una base de datos de HarperDB. ¡Buen trabajo!

Para la próxima, planeo en revisar nuestras Funciones Personalizadas de HarperDB, el cual permite a los usuarios definir sus propios endpoints de API dentro de HarperDB. Esto significa que podemos construir nuestra aplicación completa, ¡en un sólo lugar! Mira un ejemplo de cómo HarperDB está colapsando el stack en este artículo.

Un desafío para ti 💪

Si refrescas la página Chat, el nombre de usuario del usuario y la sala desaparecerán. Mira si puedes prevenir que esta información se pierda cuando el usuario refresque la página. Pista: ¡el local storage podría ser útil!

¡Gracias por leer!

Si te pareció útil este artículo, puedes: