Anatomia de un ChatBox: Web Components, RxJS, WebSocket y Drag & Drop en 400 lineas
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:
mapextrae el valor y la teclafilterdeja pasar solo Enter con texto no vaciomapextrae 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:
Cambio de posicionamiento: El CSS inicial usa
bottom: 16px; right: 16px. Al arrastrar, cambiamos atop/left. Si no limpiamosbottom/rightponiendolos aauto, el navegador intenta respetar ambos y el elemento no se mueve bien.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.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">💬</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 JSONEl 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
Web Components: Encapsulacion real sin framework. Un
<chat-box>funciona en cualquier pagina. React, Vue, WordPress, HTML estatico. Da igual.Shadow DOM: Los estilos no se mezclan. Fundamental cuando tu componente vive dentro de paginas que no controlas.
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.
Drag and Drop nativo: No necesitas una libreria. Tres eventos (
mousedown,mousemove,mouseup), un offset, y clamping al viewport. 40 lineas.Deteccion de entorno:
window.location.protocolpara decidirws://vswss://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.
Comentarios (0)
Sin comentarios todavia. Se el primero!
Deja un comentario