Anatomia de un ChatBox: Web Components, RxJS, WebSocket y Drag & Drop en 400 lineas


14 de marzo de 2026

Hoy vamos a destripar un componente completo: un chat en tiempo real que se conecta por WebSocket, es arrastrable por la pantalla, se minimiza a una burbuja, y funciona en cualquier pagina sin dependencias externas (excepto RxJS). Todo encapsulado en un Web Component con Shadow DOM.

No es un tutorial de "haz click aqui". Es una diseccion. Vamos a ver por que cada decision y como encajan las piezas.

Puedes verlo funcionando ahora mismo en pascualmg.dev/blog.

La idea: un <chat-box> que se pega a cualquier pagina

El objetivo es poder escribir esto en cualquier HTML:

<script src="rxjs.umd.min.js"></script>
<script type="module" src="ChatBox.js"></script>
<chat-box group="mi-sala"></chat-box>

Y que aparezca un chat funcional en la esquina inferior derecha. Sin CSS global. Sin conflictos con los estilos de la pagina. Sin framework.

Tres lineas. Eso es lo que un Web Component bien hecho deberia requerir.

Web Components: la base

Un Web Component es una clase que extiende HTMLElement. El navegador la registra con customElements.define y a partir de ahi puedes usarla como cualquier tag HTML.

class ChatBox extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({mode: 'open'});
        this.webSocket = null;
        this.minimized = false;
        this.dragging = false;
        this.dragOffset = {x: 0, y: 0};
    }
}

window.customElements.define('chat-box', ChatBox);

Shadow DOM: la encapsulacion real

this.attachShadow({mode: 'open'}) crea un arbol DOM aislado. Los estilos de la pagina no le afectan. Los estilos del componente no se escapan. Es como un iframe, pero sin iframe.

Esto es critico en nuestro caso: el ChatBox va incrustado en un blog con su propio CSS. Sin Shadow DOM, los estilos del blog machacarian los del chat (o al reves). Con Shadow DOM, cada uno vive en su mundo.

// Todo el HTML y CSS va al shadowRoot, no al DOM principal
this.shadowRoot.innerHTML = `
    <style>
        .chat-container { /* solo existe aqui dentro */ }
    </style>
    <div class="chat-container">...</div>
`;

La contrapartida: las variables CSS de la pagina (var(--color-primary)) no entran en el Shadow DOM. Hay que usar colores reales. Esto nos mordio al principio: el chat era transparente porque usaba variables que no existian.

Ciclo de vida: connectedCallback

Cuando el navegador inserta el elemento en el DOM, llama a connectedCallback. Aqui es donde arrancamos todo:

connectedCallback() {
    // 1. Detectar entorno (produccion vs desarrollo)
    const isSecure = window.location.protocol === 'https:';
    const host = this.getAttribute("host") ||
        (isSecure ? 'ws.pascualmg.dev' : window.location.hostname);
    const port = this.getAttribute("port") ||
        (isSecure ? '' : '8001');
    const protocol = isSecure ? 'wss' : 'ws';
    const uri = port
        ? `${protocol}://${host}:${port}`
        : `${protocol}://${host}`;

    // 2. Renderizar HTML
    this.render(group);

    // 3. Cachear referencias al DOM
    this.elements = {
        chatContainer: this.shadowRoot.querySelector('.chat-container'),
        chatBox: this.shadowRoot.querySelector('.scrollable'),
        messageInput: this.shadowRoot.querySelector('.message-input'),
        // ...
    };

    // 4. Conectar comportamientos
    this.setupMinimize();
    this.setupDrag();

    // 5. Conectar WebSocket (RxJS)
    this.SocketMessage$(uri).subscribe(/*...*/);
    this.userInput$().subscribe(/*...*/);
}

La deteccion de entorno es interesante: en produccion (HTTPS), el WebSocket va por wss://ws.pascualmg.dev sin puerto (Cloudflare maneja el routing en 443). En desarrollo (HTTP), va a ws://localhost:8001 directo. El componente lo resuelve solo. Zero config.

RxJS: WebSocket como Observable

Aqui es donde la cosa se pone bonita. En vez de manejar los callbacks del WebSocket a mano (onopen, onmessage, onerror, onclose), lo envolvemos en un Observable de RxJS:

SocketMessage$(url) {
    return new rxjs.Observable(subscriber => {
        this.webSocket = new WebSocket(url);

        this.webSocket.onopen = () => {
            this.connectedButton(true);
        };

        this.webSocket.onmessage = event => {
            subscriber.next(event);  // cada mensaje -> next()
        };

        this.webSocket.onerror = error => {
            subscriber.error(error);
        };

        this.webSocket.onclose = event => {
            if (!event.wasClean) {
                subscriber.error('Connection lost');
            } else {
                subscriber.complete();
            }
        };

        // Teardown: se ejecuta al desuscribirse
        return () => {
            this.webSocket.close(1000, 'Normal close');
        };
    })

Por que un Observable y no callbacks directos

Porque un Observable te da composicion. Puedes encadenar operadores:

.pipe(
    rxjs.operators.retryWhen((errors) => errors.pipe(
        rxjs.operators.tap(val =>
            console.error('Connection failed:', val)
        ),
        rxjs.operators.switchMap((error, index) =>
            (index < 10)
                ? rxjs.of(error).pipe(
                      rxjs.operators.delay(2000)
                  )
                : rxjs.throwError(() =>
                      new Error('Failed after 10 attempts')
                  )
        )
    ))
);

retryWhen reconecta automaticamente hasta 10 veces, con 2 segundos de espera entre intentos. Si el servidor se reinicia, el chat se recupera solo. Con callbacks tendrias que implementar esta logica a mano con setTimeout y contadores manuales. Con RxJS son 8 lineas declarativas.

El input del usuario: otro Observable

El input del teclado tambien es un stream:

userInput$() {
    return rxjs.fromEvent(this.elements.messageInput, 'keypress')
        .pipe(
            rxjs.operators.map(event => ({
                value: this.elements.messageInput.value.trim(),
                key: event.key
            })),
            rxjs.operators.filter(({value, key}) =>
                key === 'Enter' && value !== ''
            ),
            rxjs.operators.map(({value}) => value)
        );
}

fromEvent convierte eventos DOM en un Observable. Luego:

  1. map extrae el valor y la tecla
  2. filter deja pasar solo Enter con texto no vacio
  3. map extrae solo el valor

El resultado es un stream limpio de mensajes listos para enviar. El subscribe simplemente los manda por el WebSocket:

this.userInput$()
    .subscribe(this.sendMessageToChat(this.webSocket));

La convencion $ al final del nombre (userInput$, SocketMessage$, closeButtonClick$) indica que el metodo devuelve un Observable. Es una convencion de RxJS que hace el codigo mas legible: si ves $, sabes que es un stream.

Drag and Drop: tres eventos, cero librerias

El drag and drop del chat se implementa con tres eventos nativos del navegador: mousedown, mousemove, mouseup. Nada mas.

setupDrag() {
    const bar = this.elements.chatBar;
    const container = this.elements.chatContainer;
    const bubble = this.elements.minimizedBubble;

mousedown: recordar donde agarraste

const onMouseDown = (e) => {
    // No arrastrar si clickeas un boton
    if (e.target.tagName === 'BUTTON') return;

    this.dragging = true;
    const target = this.minimized ? bubble : container;
    const rect = target.getBoundingClientRect();

    // Offset: distancia del cursor al borde del elemento
    this.dragOffset.x = e.clientX - rect.left;
    this.dragOffset.y = e.clientY - rect.top;

    bar.style.cursor = 'grabbing';
    e.preventDefault();
};

El truco clave es el offset. Sin el, el elemento saltaria a la posicion del cursor al empezar a arrastrar. Con el offset, calculamos la distancia entre el cursor y la esquina superior izquierda del elemento, y la mantenemos durante todo el arrastre.

e.preventDefault() evita que el navegador seleccione texto mientras arrastras.

mousemove: mover con limites

const onMouseMove = (e) => {
    if (!this.dragging) return;

    const target = this.minimized ? bubble : container;
    const x = e.clientX - this.dragOffset.x;
    const y = e.clientY - this.dragOffset.y;

    // Cambiar de bottom/right a top/left
    target.style.bottom = 'auto';
    target.style.right = 'auto';
    target.style.left = Math.max(0,
        Math.min(x, window.innerWidth - target.offsetWidth)
    ) + 'px';
    target.style.top = Math.max(0,
        Math.min(y, window.innerHeight - target.offsetHeight)
    ) + 'px';

    // Sincronizar posicion con el otro elemento
    const other = this.minimized ? container : bubble;
    other.style.bottom = 'auto';
    other.style.right = 'auto';
    other.style.left = target.style.left;
    other.style.top = target.style.top;
};

Hay tres sutilezas aqui:

  1. Cambio de posicionamiento: El CSS inicial usa bottom: 16px; right: 16px. Al arrastrar, cambiamos a top/left. Si no limpiamos bottom/right poniendolos a auto, el navegador intenta respetar ambos y el elemento no se mueve bien.

  2. Clamping: Math.max(0, Math.min(x, window.innerWidth - target.offsetWidth)) asegura que el chat no se salga de la ventana. Nunca puedes arrastrarlo fuera del viewport.

  3. Sincronizacion: Cuando arrastras el chat expandido, la burbuja minimizada se mueve a la misma posicion (y viceversa). Asi al minimizar/restaurar, el componente aparece donde lo dejaste.

mouseup: soltar

const onMouseUp = () => {
    this.dragging = false;
    bar.style.cursor = 'grab';
};

// Eventos: mousedown en bar/bubble, move/up en document
bar.addEventListener('mousedown', onMouseDown);
bubble.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);

Detalle importante: mousemove y mouseup van en document, no en el elemento. Si los pones en el elemento, pierdes el tracking cuando el cursor se mueve rapido y sale fuera del componente (el evento deja de dispararse). En document siempre captas el movimiento.

Minimize: toggle entre dos mundos

El minimize es elegantemente simple. Dos elementos: el chat completo y una burbuja. Solo uno es visible a la vez.

toggleMinimize() {
    this.minimized = !this.minimized;
    this.elements.chatContainer.classList.toggle('hidden', this.minimized);
    this.elements.minimizedBubble.classList.toggle('hidden', !this.minimized);
    if (!this.minimized) {
        this.elements.messageInput.focus();
    }
}

La burbuja minimizada es un circulo verde con un icono de chat:

<div class="minimized-bubble hidden" title="Abrir chat">&#128172;</div>
.minimized-bubble {
    position: fixed;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: #00d4aa;
    cursor: pointer;
    box-shadow: 0 4px 16px rgba(0,212,170,0.3);
    z-index: 9999;
}

.minimized-bubble:hover {
    transform: scale(1.1);  /* feedback visual sutil */
}

.minimized-bubble.hidden {
    display: none;
}

Al restaurar, hacemos focus() en el input para que puedas escribir inmediatamente.

El CSS: position fixed y z-index

Todo el componente usa position: fixed, que lo posiciona relativo al viewport, no al contenedor padre. Esto es lo que permite que flote sobre el contenido del blog.

.chat-container {
    position: fixed;
    bottom: 16px;
    right: 16px;
    z-index: 9999;
}

z-index: 9999 asegura que quede por encima de cualquier otro elemento. El overflow: hidden en el container es critico: sin el, el contenido del chat se desbordaria y romperia los bordes redondeados.

El scroll

.scrollable {
    flex: 1;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 6px;
}

flex: 1 hace que el area de mensajes ocupe todo el espacio disponible entre la barra y el input. flex-direction: column apila los mensajes de arriba a abajo. gap: 6px los separa sin necesidad de margins.

Los mensajes propios se alinean a la derecha con un color diferente:

.scrollable div.own {
    background: #0a3d62;
    align-self: flex-end;
}

align-self: flex-end es el truco: dentro de un flex column, mueve el elemento al lado derecho. Simple y efectivo.

La conexion con el backend

El backend es Ratchet sobre ReactPHP. Un solo proceso PHP sirve HTTP en el puerto 80 y WebSocket en el 8001, compartiendo el mismo event loop. Pero eso es otro post.

Desde el frontend, la unica dependencia con el backend es el formato del mensaje:

// Recibir
const { msg, uuid } = JSON.parse(messageEvent.data);

// Enviar
webSocket.send(value);  // texto plano, el server lo envuelve en JSON

El servidor asigna un UUID a cada conexion y devuelve {msg, uuid} en cada broadcast. El frontend podria usar ese UUID para distinguir entre usuarios, implementar "fulano esta escribiendo…", o cualquier otra logica. Por ahora simplemente mostramos el mensaje.

Resumen: que aprender de esto

  1. Web Components: Encapsulacion real sin framework. Un <chat-box> funciona en cualquier pagina. React, Vue, WordPress, HTML estatico. Da igual.

  2. Shadow DOM: Los estilos no se mezclan. Fundamental cuando tu componente vive dentro de paginas que no controlas.

  3. RxJS + WebSocket: Envolver el WebSocket en un Observable te da retry, composicion y desuscripcion limpia gratis. El boilerplate de callbacks se convierte en un pipeline declarativo.

  4. Drag and Drop nativo: No necesitas una libreria. Tres eventos (mousedown, mousemove, mouseup), un offset, y clamping al viewport. 40 lineas.

  5. Deteccion de entorno: window.location.protocol para decidir ws:// vs wss:// y el host correcto. El componente se adapta solo.

El codigo completo esta en GitHub y lo puedes probar en el blog. Arrastra la barra, minimiza la burbuja, abre dos pestanas y escribe. Todo eso en 426 lineas de JavaScript vanilla.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario