Creación de chat con WS + NodeJS + VueJS

Con la idea de interiorizarme y aprender sobre los Web Sockets (WS) me decidí a realizar un nuevo proyecto usando NodeJS con framework Express y VueJS para el front.

¿Por que se eligieron dichas tecnologías?

Se optó por usar NodeJS por que tiene un muy buen manejo de WebSockets, ya que con el paquete express-ws podremos realizarlo de forma sencilla, dicho paquete se apoya en el framework Express; el cual me parece atractivo al no obligar al programador a usar un patrón de diseño en específico y es muy liviano.

NodeJS también tiene la ventaja, por sobre PHP, de poder almacenar información en memoria lo que nos permite prescindir de base de datos, ya que PHP al no guardar estados no lo permite y deberíamos encontrar alguna forma de mantener la persistencia de los datos.

El front podría haberlo desarrollado en Angular, framework con el cual vengo trabajando desde ya hace un tiempo, pero en cambio esta vez opté por VueJS, por que el mismo es más liviano, consume menos recursos en proceso de desarrollo (RAM principalmente), las APPs generadas son más livianas.

Y VueJS en si es más sencillo de usar y da menos problemas con respecto al manejo de dependencias, es más debugeable que Angular (aunque Angular mejoró bastante en dicho aspecto en el último tiempo)

Realmente no encuentro motivo para volver a Angular, por lo que Vue ya es mi framework favorito.

Otro punto que para mi suma es el no tener que usar TypeScript lenguaje que no termino de tomar muy en serio por que luego se transpila a JavaScript y para mi el tipado artificial que aplica solo suma una capa más sin aportar mucho, se pueden evitar los problemas de los lenguajes no tipados prestando un poco de atención.

¿Qué módulos conformarán el proyecto?

Como en muchos sistemas webs, el proyecto se dividirá en un Backend, el cual se encargará de proporcionar un endpoint al cual los clientes se podrán conectar por medio de WebSockets, llevará el listado de clientes conectados y retransmitirá los mensajes a todos los clientes conectados.

Y un front que se encargará solo de establecer la conexión e intercambiar mensajes.

El Backend

El código del backend es muy sencillo y de hecho hasta lo tendríamos en un solo archivo server.js:

let express = require('express');
let app = express();
let expressWs = require('express-ws')(app);
let uuid = require("uuid")
require("dotenv").config()

let registro_clientes = []
let reporte_conectados = []

expressWs.getWss().on('connection', function(ws) {
  ws['id_conexion'] = uuid.v4()
});

app.ws('/', function(ws, req) {  
  ws.on('message', function(msg) {

    let msgJson = null

    try {
      msgJson = JSON.parse( msg )
      console.log(req.id)

      if (msgJson.hasOwnProperty('accion')){
        switch(msgJson.accion){
          case 'registro':
            msgJson.nombre = msgJson.nombre.replace(/]+(>|$)/g, "")

            if (msgJson.nombre.length < 4){
              ws.send(JSON.stringify({
                accion: 'alerta',
                msg: 'El nombre de usuario debe tener al menos 4 caracteres'
              }))
              return;
            }

            let registro = {
              id: uuid.v4(),
              nombre: msgJson.nombre,
              accion: 'registro',
              id_conexion: this.id_conexion
            }

            //comprobamos que no haya alguien registrado con el mismo nombre
            for(let c=0; c < registro_clientes.length; c++){
              if (registro.nombre == registro_clientes[c].nombre){
                ws.send(JSON.stringify({
                  accion: 'alerta',
                  msg: 'El usuario especificado ya existe'
                }))
                return;
              }
            }
            
            registro_clientes.push( registro )
            reporte_conectados.push( registro.nombre )
    
            console.log('se registro nuevo usuario', registro)
            ws.send(JSON.stringify(registro))

            let clientes = expressWs.getWss().clients
    
            clientes.forEach(cliente => {
              cliente.send(JSON.stringify({
                accion: 'mensaje_sys',
                msg: registro.nombre + ' Se ha unido a la sala'
              }))
            })
          break;
          case 'mensaje':
            //comprobamos que el mensaje provenga de un cliente registrado
            let encontrado = false
            for(let c=0; c < registro_clientes.length; c++){
              if (msgJson.autor.id == registro_clientes[c].id && msgJson.autor.nombre == registro_clientes[c].nombre ){
                encontrado = true;
                break;
              }
            }

            //si es asi lo reenviamos al resto de los clientes
            if (encontrado === true){
              //se hace sanitizacion
              msgJson.texto = msgJson.texto.replace(/]+(>|$)/g, "")
              //se hace validacion 
              if (msgJson.texto.length > 500){
                break;
              }

              let clientes = expressWs.getWss().clients
    
              clientes.forEach(cliente => {
                cliente.send(JSON.stringify(msgJson))
              })
            }
            
          break;
        }
        
      }
    } catch( error ){
      console.log('error', error)
    }
    
  });

  
  ws.on('close', function(code) {
    console.log('desconectado', this.id_conexion)

    let nombre = ''
    for(let c=0; c < registro_clientes.length; c++){
      if (registro_clientes[c].id_conexion == this.id_conexion){
        nombre = registro_clientes[c].nombre
        registro_clientes.splice(c,1)
        reporte_conectados.splice(c,1)
        break;
      }
    }

    if (nombre != ''){
      let clientes = expressWs.getWss().clients
      clientes.forEach(cliente => {
        cliente.send(JSON.stringify({
          accion: 'mensaje_sys',
          msg: nombre+' ha abandonado la sala'
        }))
      })
    }
    
  })

});

setInterval(()=>{
  let clientes = expressWs.getWss().clients
  clientes.forEach(cliente => {
    cliente.send(JSON.stringify({
      accion: 'reporte_online',
      reporte: reporte_conectados
    }))
  })
}, 500)

app.listen(process.env.PUERTO);
console.log('puerto', process.env.PUERTO)

Creo que algo interesante a resaltar es que el manejo de la comunicación se realiza por medio de eventos.

Por ej nos podremos subscribir al evento “connection” que se dispara por cada nueva conexión establecida por el cliente

expressWs.getWss().on('connection', function(ws) {
  ws['id_conexion'] = uuid.v4()
});

Este evento lo aprovechamos para poder generar identificadores únicos para cada cliente conectado.

Luego usamos Express como intermediario para atender los WS

app.ws('/', function(ws, req) {
....

Luego podremos atender el evento “message” que se disparará por cada mensaje recibido de algún cliente en particular.

Dichos mensajes usarán el formato JSON, por que creo que es la forma más facil de manejar datos estructurados, que puedan ser codificados y decodificados facilmente.

Y es necesario definir una estructura básica a la información ya que existen varios tipos de mensajes a ser enviados y recibidos.

Tipos de mensajes

  • reporte_online: Este mensaje se envía de forma períodica a todos los clientes conectados, y se usa para informar sobre los usuarios que actualmente están online, un ej del mismo sería:
    { “accion”:”reporte_online”, “reporte”: [ “Flor”, “Pepe”, “María”, “Juan” ] }
  • mensaje_sys: Se trata de un mensaje del sistema, que será mostrado a todos los clientes, se usa para informar por ej cuando alguien ingresa o egresa de la sala, ej del mismo sería: { “accion”:”mensaje_sys”, “msg”: “Flor ha abandonado la sala” }
  • alerta: Se usa para mostrar alertas o mensajes de error, por ej: { “accion”:”alerta”, “msg”: “El usuario especificado ya existe” }
  • registro: Se usa para registrar un nuevo usuario, ya que ni bien se ingresa al chat se solicita un nombre de usuario con el cual interectuar, para lo cual en el submit del formulario de nombre de usuario, se envia un mensaje de tipo “registro”, por ej: { “accion”:”registro”, “nombre”: “Pepe” }
  • mensaje: Se trata de un mensaje en sis mismo enviado por un usuario registrado, un ej: { “accion”:”mensaje”, “autor”: { “id”: “dsafdsfawe5433245-43534w”, “nombre”: “Marta” }, “texto” :”Hola Pepe!” }.

Algunas comprobaciones básicas

Se procede a sanitizar la entrada de caracteres de las entradas tanto al intentar elegir un nombre de usuario como en los mensajes en si mismos.

Excluyendo los caracteres que pudieran corresponder a tags html, ya que la idea es que se utilice solo texto, y quitar algunos caracteres problemáticos.

Si bien luego VueJS en el front por si solo salinitiza los datos para que no se incruste por ej código JS en los mensajes / CSS / HTML, creo que está bueno hacerlo de todos modos.

Para eso basta solo con usar: replace(/]+(>|$)/g, “”)

En el nombre de usuario se establece una longitud mínima del mismo y en el texto de los mensajes una longitud máxima.

Broadcast

Para el envio masivo de mensajes, primero se debe obtener el listado de clientes conectados, el cual se realiza usando:

let clientes = expressWs.getWss().clients

Luego se pueden recorrer los clientes usando un for, por ej:

clientes.forEach(cliente => {
    cliente.send(JSON.stringify({
      accion: 'reporte_online',
      reporte: reporte_conectados
    }))
  })

El Frontend

El frontend en si mismo no tiene mucho, toda la lógica la implementé en un nuevo componente llamado VentanaChat.

El cual se encarga de establecer la conexión e intercambiar mensajes.

Para realizar la vista usé Bootstrap ya que creo que el framework de estilos más facil de usar, además existe el packete Bootstrap-vue3 que lo implementa y provee los componentes bàsicos para ser facilmente utilizable en Vue.

Por lo que el template nos quedaría así:

<template>
  <div class="row">
    <div class="col p-3">

      <div class="row">
        <div class="col mensaje-cont">

          <div class="row" v-for="(msg) in mensajes" :key="msg">
            <div class="col">
              <span class="badge" :class="{ 'bg-secondary':msg.autor.id != datos_usuario.id, 'bg-primary': msg.autor.id == datos_usuario.id }">{{msg.autor.nombre}}:</span> {{msg.texto}}
            </div>
          </div>

        </div>

        <div class="col-2">
          <div claSS="row conectados-tit">
            <div class="col">
              Conectados ({{online.length}})
            </div>
          </div>
          <div class="row" v-for="(user) in online" :key="user">
            <div class="col">
              {{user}}
            </div>
          </div>
        </div>
      </div>

      <div class="row mt-3">
        <div class="col">
          <b-form-input
                id="input-msg"
                v-model="mensaje.texto"
                type="text" placeholder="Mensaje"
                required></b-form-input>
        </div>

        <div class="col-auto">
          <b-button variant="success" @click="enviarMensaje()">Enviar</b-button>
        </div>
      </div>
     
    </div>
  </div>  

  <div class="app-modal h-100" v-if="modal.mostrar">
    <div class="row h-100">
      <div class="mt-auto mb-auto col-12 col-sm-8 offset-sm-2 col-md-6 offset-md-3 col-lg-4 offset-lg-4">

        <div class="card">
          <div class="card-header">
            <h5 class="card-title">Ingrese el nombre a mostrar</h5>
          </div>
          <div class="card-body">
            
            <div class="row mb-3">
              <div class="col">
                <b-form-input
                  id="input-nombre"
                  v-model="modelo_registro.nombre"
                  type="text" placeholder="Nombre"
                  required></b-form-input>
              </div>
            </div>

            <div class="row">
              <div class="col">
                <b-button variant="success" @click="registrarse()">Registrarse</b-button>
              </div>
            </div>
  
          </div>
        </div>

      </div>
    </div>
  </div>
</template>

El código JS encargado de establecer la conexión también es sencillo:

<script setup>
  import { ref, onMounted } from 'vue';

  const mensajes  = ref([])
  const conexion  = ref({})
  const online    = ref([])

  const mensaje = ref({
    texto: '',
    accion: 'mensaje',
    autor: {}
  })

  const datos_usuario = ref({
    id: -1,
    nombre: 'Juan Perez'
  })

  const modelo_registro = ref({
    nombre: ''
  })

  const modal = ref({
    mostrar: false
  })

  function registrarse(){
    let registro = {
      nombre: modelo_registro.value.nombre,
      accion: 'registro'
    }
    conexion.value.send( JSON.stringify( registro ));
  }

  function enviarMensaje(){
    if (mensaje.value.texto !== ''){
      mensaje.value.autor = datos_usuario.value
      conexion.value.send( JSON.stringify( mensaje.value ))
      mensaje.value.texto = ''
    }
  }

  onMounted(async ()=>{

    if (datos_usuario.value.id == -1){
      modal.value.mostrar = true
    }

    conexion.value = new WebSocket( process.env.VUE_APP_API_URL )
    
    conexion.value.onmessage = function(event) {
      let msgRec = null
      try {
        msgRec = JSON.parse(event.data)
      } catch (error) {
        console.log(error)
      }

      if (msgRec !== null){
        switch(msgRec.accion){
          case 'mensaje':
            mensajes.value.push( msgRec )
          break;

          case 'mensaje_sys':
            mensajes.value.push( {
              texto: msgRec.msg,
              accion: 'mensaje_sys',
              autor: {
                id: -1,
                nombre: 'Sistema'
              }
            } )
          break;

          case 'registro':
            datos_usuario.value = msgRec
            modal.value.mostrar = false
          break;

          case 'alerta':
            alert(msgRec.msg)
          break;

          case 'conectado':
            alert(msgRec.id)
          break;

          case 'reporte_online':
            online.value = msgRec.reporte
          break;
        }
        
      }

    }

    conexion.value.onopen = function(event) {
      console.log(event)
      console.log("Conectado a servidor de websocket...")
    }
  })
</script>

Creo que lo importante a destacar del mismo son:

El establecimiento de la conexción con el WebSocket, que se realiza con:

conexion.value = new WebSocket( process.env.VUE_APP_API_URL )

Muy facil, no?

El “process.env.VUE_APP_API_URL” hace referencia a la variable VUE_APP_API_URL definida en un .env del proyecto, que se usa para poder configurar facilmente la url del endpoint

Y luego se atienden los eventos con:

conexion.value.onmessage = function(event) {

El proyecto en ejecución

Una vez que esten levantados tanto el back como el front, en el navegador lo podremos ver de la siguiente forma:

El código fuente está disponible en: https://github.com/Greenborn/chat_publico_back y https://github.com/Greenborn/chat_publico_front

También hay una demo funcional en: https://chat.greenborn.com.ar/

Dificultades

Se presentaron algunos problemas al intentar montar la demo, ya que fue necesario configurar Nginx para redireccionar un puerto y poder usar certificados SSL con la comunicación con el WebSocket, algo que trataré en un post aparte.

Yii2 Parche CORS error 401

A veces no queda otra que aplicar parches horribles para solucionar alguna situación que de otra forma o se gasta mucho tiempo o en cambio no hay forma de hacerlo de manera prolija.

Esta vez al parecer se trata de la segunda opción :(.

El error en cuestión

Básicamente ocurre que cuando Yii lanza una excepción con un código de error, por ej 5xx, 4xx en los encabezados de la petición no se retornan los headers correspodientes al CORS.

Lo que da como resultado que el navegador indique un error como el siguiente:

Lo que en mi caso particular, en Angular (asumo que también debe ocurrir con otros frameworks y librerías).

No permite obtener correctamente los códigos de error en los interceptores y cualquier petición.

Lo que es importante para diferenciar los errores retornados de algún endpoint, de las fallas de autenticación.

Ya que si se obtiene un código 401, el comportamiento esperado sería poder dirigir al usuario a la página de login.

Y en caso de otro tipo de error, mostrar un mensaje aclaratorio correspondiente.

Como no se pudo solucionar

Lo primero que intenté fue modificar el archivo de configuración de Nginx para asegurarse que siempre se agreguen los headers CORS, de forma similar a como se hace en el caso de archivos de imágenes y otros documentos estáticos.

Pero no funcionó, ya que el Access-Control-Allow-Origin se sobrescribía con un valor inválido.

Como si se soluciono

Des pues de buscar un tiempo en internet me encontré con un post que lo explicaba y que también hizo la prueba modificando la configuración del server.

Ese post es el siguiente: https://developpaper.com/yii2-restful-401-nginx-axios-cross-domain-settings/

La solución obtenida hasta el momento es editar directamente un archivo del core del framework

Por lo que localizaremos: vendor/yiisoft/yii2/filters/auth/AuthMethod.php

Y en dicho archivo buscar el método: handleFailure

y dejarlo así:

public function handleFailure($response)
    {
        header('Access-Control-Allow-Origin: *'); //no queda otra por el moemnto
        header('Access-Control-Allow-Headers X-Requested-With,Content-Type,x_requested_with');
        throw new UnauthorizedHttpException('Your request was made with invalid credentials.');
    }

Tal vez luego encuentre alguna solución más elegante, pero por ahora es una escusa para inaugurar una nueva etiqueta #CrotingPrograming!

Una vez guardado, llega la hora de la verdad, y haciendo un console.log del error:

Aparece como debe ser, ¡Que hermosa es la vida!

Que habría que hacer?

Sin dudar reportarlo a la policía del Web Development y de ahí insistir a ver si lo solucionan o proponer una mejor solución en los canales oficiales, por que en la última versión al día de la fecha no es algo que se haya arreglado.

Quiero guardar esta chanchada en mi repo, ¿Que hago?

Fácil, agregamos una excepción en el .gitignore

!vendor/yiisoft/yii2/filters/auth/AuthMethod.php

y si aún así se resiste, se puede usar el comando:

git add vendor/yiisoft/yii2/filters/auth/AuthMethod.php -f

El sinsentido del odio a PHP

El sinsentido del odio a PHP

No es odio, es desprecio

Primeramente creo que debería decir, en que me baso para afirmar que existe cierto odio a PHP.

Tal vez la palabra odio no sea la más justa pero creo que a fin de cuentas llama más la atención.

Más que odio, seguramente se trate de desprecio, o de ridiculización, algo que nació como un meme y que más de uno toma como algo serio.

Como yo que ahora lo tomo en serio, pero el meme no nace de la nada, el meme es humor y el humor también puede tener una critica implícita.

El meme también es una forma de reírse de uno mismo y de reírse de las cosas que de una forma u otra nos afectan.

Y claramente siendo informático, y PHP una herramienta de trabajo (discusión aparte sería saber si se trata de una herramienta o no) es una cuestión que creo que me afecta a mí a todos los que trabajamos con dicho lenguaje de programación.

El problema, a por lo que entiendo, es que muchos memes apuntan hacia el desprecio.

Los memes de Cobol

Haciendo una búsqueda rápida en Google o Duck Duck Go, podemos ver algunos memes muy buenos sobre Cobol!

Se puede ver que en lineas generales lo que predomina es una valoración de lo antiguo del lenguaje.

Tal vez si se hiciera una votación para elegir una nueva mascota para lenguaje, un dinosaurio sea la elección natural.

Y no hay nada de malo en que sea viejo o antiguo, no todo lo nuevo es mejor.

Y no hay que perder de vista que el Software tiene como principal función la resolución de problemas, no ser algo nuevo o novedoso.

Después de todo, si algo funciona bien, ¿Para que tocarlo?

¿Cuando el Software se vuelve obsoleto?

Los memes de PHP

Bueno, ahora que pasa si uno busca “memes de PHP”?

Nos encontramos con cosas como estas:

Sin palabras…
En serio? Programar en PHP es peor que vivir en la calle?
¿Cómo puede ser que usar PHP lleve pegarse un tiro a si mismo y un framework especifico un parche que no mejora las cosas?
Es PHP un nolenguaje de programación?
Sin palabras…

Bueno, y hay muchos más memes, la idea tampoco es agregarlos a todos!

¿Por qué?

Por que tratar así a un lenguaje de programación? Se supone que como informáticos también deberíamos intentar ser objetivos, creo que PHP no se merece ser tratado así.

En términos de rendimiento es muy bueno, siendo muy buena competencia con respecto a otros lenguajes de programación.

A lo mejor está un poco flojo en el manejo de Web Sockets y consumo de RAM para algunos tipos de proyectos.

Pero está demostrado que es un lenguaje vivo en continuo desarrollo.

A cada versión mejora su rendimiento y consistencia en funciones propias del lenguaje.

Se mejora en cuestiones relacionadas al consumo de recursos y tengo la seguridad de que así seguirá avanzando.

Y sobre la seguridad, es cuestión de seguir las buenas practicas de programación.

Es un lenguaje no tipado, lo que en algunos escenarios podrían dar algunos problemas, pero eso se aplica a todos los lenguajes no tipados, no solo a PHP, JavaScript también tiene sus problemas.

Pero son cuestiones que con un poco de práctica y atención se pueden salvar.

¡Es totalmente injustificado odiar o despreciar PHP!

Actualización en API de palabras al azar

En la API pública de palabras al azar, hoy se agregaron algunas mejoras.

La más importante de ellas, es el agregado de un nuevo parámetro ‘l’ que permite definir la longitud de las palabras a ser devueltas por la misma.

La otra modificación se trata de una corrección sobre el filtrado de caracteres.

Ya que en el dataset se podían llegar a encontrar algunos caracteres extraños y saltos de línea que no habían sido filtrados.

También ya no se devuelven letras en minúsculas, por lo que no existen dos o más variaciones de la misma palabra.

Por lo que ahora un ejemplo de llamada a la API con ambos parámetros sería el siguiente:

https://clientes.api.greenborn.com.ar/public-random-word?c=9&l=8

Que debería retornar 8 palabras al azar, con una longitud de 8 caracteres.

API Pública de palabras al azar

Bien, anteriormente he desarrollado un pequeño proyecto de generador de contraseñas, que surgió con la necesidad de utilizar una herramienta propia para dicho propósito y brindar un servicio más en el servidor de Greenborn.

Y la siguiente mejora de dicho proyecto consiste en la generación de contraseñas basadas en palabras.

Para hacer posible dicha mejora se hace necesario contar con una base de datos de palabras (en principio en español) y la posibilidad de consultar una al azar.

Claro que también se podría utilizar para otros propósitos.

Y ya que la API necesitaría contar con un end-point público, creo que también estaría bueno brindar la documentación necesaria para hacer posible su uso por parte de cualquier persona, para el propósito que considere necesario.

¿Cómo se usa?

Su uso es muy sencillo, sólo hace falta hacer una petición GET de la siguiente URL:

https://clientes.api.greenborn.com.ar/public-random-word

A lo cual la petición respondería con una palabra al azar.

También se puede especificar si el resultado debe devolverse en JSON o XML de acuerdo al header content-type pudiendo definirse en application/json o application/xml.

Además si en la URL se agrega el parámetro ?c=x, siendo x un número entre 1 y 100, se puede especificar la cantidad de palabras a retornar.

Condiciones de uso y Limitaciones

  • No se ofrece ninguna garantía con respecto a cualquier tipo de aspecto del servicio ofrecido.
  • El usuario será responsable de cualquier tipo de uso que le de a la API, desligándose Greenborn de cualquier tipo de responsabilidad sobre el mismo.
  • El uso GRATUITO del servicio está limitado a un máximo de 500 consultas por hora, por dirección IP.
  • Cualquier aspecto de las condiciones de uso, podrá ser modificado sin previo aviso, al igual que no se garantiza la disponibilidad del servicio (salvo que acordemos el pago por cuota de uso).
  • Actualmente el dataset es reducido y se ampliará con el tiempo.

Ampliación del dataset

La base de datos de palabras no se cargará a mano, de hecho se realiza de forma semiautomática.

A partir de la carga de archivos de texto plano de libros en español, a lo cual se llegó alrededor de 40k palabras (existiendo variaciones que agregan mayúsculas y minúsculas).

En este caso los libros en español de donde se obtuvieron las palabras, provienen de: http://www.libroteca.net/