npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@liftitapp/wa-conversations

v0.8.2

Published

Visor de conversaciones de WhatsApp del ecosistema Liftit: lista, hilo, media y composer.

Readme

@liftitapp/wa-conversations

Visor de conversaciones de WhatsApp del ecosistema Liftit — lista de conversaciones, hilo bidireccional con ticks de entrega, media y composer de texto.

Se publica en dos formas desde un solo core:

| Entry | Qué es | Para quién | |---|---|---| | ./react | ESM con React como peerDependency | lms-web-app (React 19), sin duplicar React | | ./element | Bundle self-contained que registra <wa-conversations> | Cualquier host, sin depender de su versión de React |

El elemento <wa-conversations> — configurar por atributos

Un host con SOLO HTML monta el visor completo — sin escribir una línea de JS:

<script type="module" src="…/@liftitapp/wa-conversations/dist/element/index.js"></script>

<wa-conversations
  phone-number-id="109876543210987"
  api-url="https://…/api/v1"
  token="…"
></wa-conversations>

| Atributo | Qué es | Comportamiento | |---|---|---| | phone-number-id | El scope del inbox | Reactivo: cambiarlo remonta el inbox contra el scope nuevo (WC-03) | | api-url | La base del servicio (http:/https:; otro esquema se rechaza) | Cambiarla en caliente remonta igual — es otro backend. El caso de uso normal es setearla una vez | | token | El Bearer de cada request | Se lee en CADA request, nunca se cachea: rotar el atributo al renovar sesión funciona sin más, y NO remonta el visor | | phone | Opcional — preselecciona el hilo de ese teléfono al montar | Reactivo: cambiarlo remonta el visor con la preselección nueva. Sin él, el visor arranca en la lista como siempre | | pinned | Opcional — ancla el visor a phone: ni lista ni "Volver", a cualquier ancho | Booleano por presencia (como disabled): pinned, pinned="" y pinned="pinned" anclan; sólo pinned="false" desactiva. Reactivo. Sin phone no hace nada | | refresh-interval | Opcional — cada cuántos milisegundos el visor se vuelve a leer solo | Por defecto 10 000. refresh-interval="0" apaga el refresco; un valor no numérico o negativo cae al default y no apaga nada. Reactivo y sin remontar: cambiarlo no cuesta la conversación abierta |

Sobre phone: el valor viaja verbatim al visor y tiene que ser la forma canónica del servicio — E.164 CON + (p. ej. +573112620405). Con otra forma el hilo carga igual, pero el resaltado de la fila en la lista no matchea. Normalizar el teléfono es responsabilidad del host. En layout angosto (contenedor ≤ 520 px) con phone seteado, el visor muestra solo el hilo, con un botón "Volver" hacia la lista.

Sobre pinned: es para el host que ya sabe de QUIÉN es la conversación y monta el visor dentro de su propio contexto — el drawer que abre una fila de una tabla. Ahí la lista no es una comodidad: ofrecería saltar a las conversaciones de otras personas desde una fila que habla de una sola, y el "Volver" llevaría a una bandeja que ese drawer no promete. Con pinned, el mismo elemento a 780 px sigue mostrando solo el hilo anclado; sin pinned, a ese ancho muestra lista e hilo lado a lado — que es lo que quiere el host que abre el visor como bandeja.

Sobre refresh-interval: con el hilo abierto, un mensaje nuevo aparece solo — del lifter o del bot— y los ticks avanzan de sent a delivered a read sin tocar nada; lo mismo en la bandeja (DEV-5143). El visor vuelve a leer por el MISMO fetcher/atributos de siempre: no aparece ninguna URL nueva. Con la pestaña en segundo plano no sale ni un request —un operador con ocho pestañas del LMS no puede generar tráfico en las ocho— y al volver a la pestaña se lee de inmediato, sin esperar el próximo intervalo. Al desmontar el elemento no queda ningún timer vivo.

Con configuración incompleta (atributo ausente o vacío, y sin property fetcher que los supla), el elemento pinta un panel dentro del shadow DOM que nombra los atributos faltantes — nombres, jamás valores — y remite a este README.

La property fetcher — precedencia sobre los atributos

Quien traduce los atributos a URLs es un default-fetcher que vive en el entry ./element — el único lugar del paquete autorizado a mapear descriptores→URL. Un host que puede ejecutar JS tiene una alternativa mejor: asignar la property fetcher (el.fetcher = miFetcher). Con la property puesta, los atributos quedan ignorados y el paquete no ve ni URLs ni tokens — la asignación funciona incluso ANTES de que cargue el bundle (lazy upgrade).

Recomendada para hosts que puedan: un token en un atributo es visible en el DOM del host (trade-off aceptado de la API por atributos); la property lo mantiene cerrado en un closure.

Sesión vencida: wa-conversations:unauthorized

Ante un 401, el elemento despacha el evento wa-conversations:unauthorized (bubbles + composed: llega a un listener en document aunque el elemento viva dentro de otro shadow root). El detail es { status: 401 } — jamás lleva token ni URL.

Contrato de renovación: si el host rota el atributo token — síncrono en el handler, o async dentro de ~3 s (el caso Keycloak updateToken()) — la request en vuelo se reintenta una sola vez con el token nuevo, sin perder el estado de la UI. Sin listener o sin cambio, el visor pinta su error de sesión normal; el elemento nunca reintenta solo.

document.addEventListener('wa-conversations:unauthorized', async () => {
  const token = await renovarSesion(); // p. ej. keycloak.updateToken()
  visor.setAttribute('token', token);
});

Dale altura al elemento

El visor toma su altura del elemento (:host { height: 100% }), y height: 100% degrada a auto si ningún ancestro define altura: sin un height del host, el visor crece con el contenido y quien scrollea es la página. Dale al elemento (o a su contenedor) una altura explícita (height: 100% en una cadena con altura, flex: 1 en un layout flex, etc.).

CORS

Un api-url en otro origen exige CORS_ALLOWED_ORIGINS correcto en el servicio (wa_conversations_service) por ambiente, y el header authorization vuelve cada request una request con preflight: el servicio debe permitir el header y el origen. Es configuración del servicio, no del paquete — si el elemento "no carga" cross-origin, el diagnóstico empieza ahí.

El costo del bundle self-contained (WC-05)

pnpm build mide y reporta el tamaño en cada corrida (verify-externals). Al cierre de la Fase 7 (composer): 233.4 kB raw / 71.4 kB gzip (venía de 219.8 / 67.7 al cierre de la Fase 6). La referencia discutida es 300 kB raw / 90 kB gzip, deliberadamente SIN gate en CI (D-05): el número se reporta para que el costo sea visible, no para romper builds.

Instalación

pnpm add @liftitapp/wa-conversations

Se publica al registry público de npm (registry.npmjs.org) bajo el scope @liftitapp, con access public: el install es anónimo y ningún consumidor necesita credenciales ni .npmrc. No hay ningún registry cerrado de por medio — es lo que permite que lms-web-app, backoffice-v2-lite y FlexOS lo consuman sin pedirle un token a nadie.

El entry ./react necesita react y react-dom >=18 en el host: están declarados como peerDependencies, así que el paquete usa los del host y nunca trae los suyos. El entry ./element no necesita nada: el bundle ya los trae adentro.

Elegir un entry

Un host elige UN entry, no los dos. Importar ./react y ./element en la misma página mete dos copias de React en el documento y produce el error de hook inválido — el fallo más caro y menos legible de este paquete, porque el stack trace apunta a React y no acá. Si aparece, el diagnóstico es pnpm why react en el host: si hay más de una versión resuelta, el problema es éste.

| Si el host… | Usá | Por qué | |---|---|---| | ya tiene React 18+ y lo controla (lms-web-app) | ./react | React queda afuera del artefacto: una sola copia en la página | | tiene otro React, o ninguno (backoffice-v2-lite, FlexOS) | ./element | El bundle es self-contained y no toca el React del host |

Dos costos honestos del empaquetado, para que nadie los descubra en producción:

  • Los peers están marcados como opcionales para no llenar de warnings de install a quien sólo usa ./element. La contracara es que quien use ./react sin React instalado no recibe ningún warning y falla en runtime.
  • Los subpaths del exports map no resuelven con TypeScript anterior a 4.7. backoffice-v2-lite está en 3.3.3333, así que ese host consume el elemento con una etiqueta <script type="module"> y no con un import tipado. Es una limitación aceptada a conciencia, no un bug.

ConversationList — el panel de la lista

Lo primero del visor que ya se puede montar de verdad (DEV-4986). El host inyecta su fetcher y el panel pinta las conversaciones; la selección es controlada, o sea que quién está elegido lo decide el host:

import { ConversationList } from '@liftitapp/wa-conversations/react';

const fetcher = async (request, options) => {
  // La ruta, el tenant y la credencial son del HOST. El paquete recibe un descriptor semántico
  // ({ resource: 'conversations', phone?, from?, to?, cursor? }) y nunca arma una URL.
  const res = await fetch(miUrlPara(request), {
    headers: { authorization: `Bearer ${token}` },
    ...options,
  });
  return res.json();
};

<ConversationList fetcher={fetcher} selectedPhone={elegido} onSelect={(item) => setElegido(item.phone)} />;

Qué conviene saber antes de usarlo:

  • No hay props de ruta ni de credencial (orgId, phoneNumberId, apiUrl, token) y no las va a haber: ver Regla de diseño que no se puede romper. El scope del inbox lo cierra el fetcher por closure.

  • El service_state de cada fila se pinta tal como viene del backend, sin traducir. Es deliberado: ese vocabulario lo define cada servicio emisor (self_onboarding_whatsapp manda sus pasos de flujo, otro bot mandaría los suyos), y mapearlo a etiquetas lindas acoplaría el visor a un producto puntual. Un emisor sin flujo manda null y esa parte de la fila no se pinta.

  • El CSS viaja adentro del componente, con todos los selectores prefijados con .wa-list. No hay ningún .css que importar y el paquete no trae dependencias de UI: monta igual en lms-web-app y en backoffice-v2-lite.

  • Angosto/ancho lo decide el ancho del contenedor, no el del viewport — el panel suele vivir en una columna de ~380 px de una pantalla de 1920. El mecanismo está exportado como useContainerLayout(ref).

  • La bandeja se refresca sola cada 10 s mientras está a la vista (DEV-5143), y el hilo también. Se ajusta con refreshIntervalMs y se apaga con refreshIntervalMs={0}, en ConversationList, en ConversationThread y en el WaConversations completo. Quien pinte su propio markup se lleva el latido con los hooks (useConversations / useThread aceptan la misma opción y exponen refresh()), así que no hay que reimplementarlo afuera. Sin pestaña visible no hay requests.

  • El switch de dos vistas todavía no está completo: "en pantalla angosta la lista y el hilo son dos vistas, no dos columnas" necesita el hilo, que es DEV-4987. Hoy existe el mecanismo y el layout angosto del panel; el shell que muestra el hilo en lugar de la lista llega con esa fase.

El composer — responderle al lifter (DEV-4995)

El composer de texto aparece debajo del hilo y sólo cuando el sobre del hilo trae can_reply: true. No hay nada que activar: ConversationThread (y por lo tanto WaConversations y el elemento) ya lo monta.

Lo que hay que saber antes de usarlo:

  • can_reply ausente se lee como false. El campo es aditivo del contrato de envío del operador v0 y un servidor que no lo manda tampoco tiene /reply: el composer no se pinta y un operador de solo lectura ve el visor exactamente como antes. Cuando wa_conversations_service (DEV-4993) despliegue, se enciende solo.
  • Solo texto. Al lifter no se le mandan imágenes ni documentos: es decisión de producto, no un pendiente.
  • window_state tiene tres valores y closedunverified. closed reemplaza el composer por un panel que explica que la conversación está fría y ofrece devolverle la conversación al bot; unverified sólo agrega un aviso de "reintentá" y no toca el composer.
  • «Devolver la conversación al bot» libera el takeover y nada más. Llama a /release, que suelta el control para que el bot vuelva a manejar el hilo: no manda ninguna plantilla, no reactiva a nadie y no te devuelve la palabra a vos. Tras el 200 el composer sigue bloqueado — la ventana de 24 h se abre cuando la persona conteste. Es idempotente: liberar algo que no estaba tomado devuelve 200 igual.
  • La burbuja optimista se reconcilia por client_message_id. Desde la extensión E3 del contrato del hilo, ThreadMessage trae ese campo también en GET .../thread, así que un refetch que llegue antes de la respuesta del envío ya trae la saliente persistida con el mismo id y mergeThread la une por identidad en vez de pintarla dos veces. Es null en todo lo que no escribió un operador desde el visor (entrantes, salientes del bot, salientes previas al cambio): ahí siguen mandando el dedup por wamid y la detección de huérfanos por direction.
  • El relanzamiento en frío con plantilla NO está en este visor. Ya existe en lms-mf-lifters, en el header del drawer de la misma tabla del embudo donde el visor va embebido — ahí el MF tiene cargado el detalle del embudo (step/stage), que es de backoffice_api y no del dominio conversación. El copy del panel frío lo menciona; el paquete no lo ejecuta.

Si inyectás tu propio fetcher, tiene que atender tres descriptores nuevos

WaRequest es una unión discriminada, así que TypeScript te lo va a decir al actualizar:

// POST {API}/conversations/{phone_number_id}/{phone}/reply
// body: { text, client_message_id }   ← el snake_case lo armás vos, el descriptor es camelCase
{ resource: 'reply', phone, text, clientMessageId }

// POST {API}/conversations/{phone_number_id}/{phone}/release   (sin cuerpo, 200 OK)
{ resource: 'release', phone }

// POST {API}/conversations/{phone_number_id}/{phone}/suggestion/discard   (sin cuerpo, 200 OK)
{ resource: 'discardSuggestion', phone }

El tercero es POST y no DELETE, y no es capricho: el CORS del store admite GET y POST, así que un DELETE moriría en el preflight del navegador funcionando perfecto con curl — se leería como un bug del front y no lo sería. Un host que no lo implemente nunca lo recibe, salvo que el store empiece a emitir sugerencias (ver abajo).

Y el rechazo tiene que llevar el cuerpo, no sólo el status: window_closed y window_unverified comparten el 409 y el contrato manda decidir por error_code. Lanzá un WaFetchError (exportado por el paquete) o cualquier error con el cuerpo parseado colgado de .body; sin estructura, el paquete degrada a "reintentable" y no puede distinguir los dos casos.

import { WaFetchError } from '@liftitapp/wa-conversations/react';

if (!res.ok) throw new WaFetchError(res.status, `${res.status}`, await res.json());

Para pintar tu propia caja de texto, el estado sale por useComposer — ahí viven las reglas del contrato (decidir por error_code, respetar retryable, un client_message_id por mensaje).

La burbuja de sugerencia del bot

Durante el takeover el bot sigue trabajando, pero su voz está cortada: el mensaje que produce muere en el gate y el operador no se entera de que existe. El visor lo muestra como una burbuja atenuada, sin tick y con la leyenda de que no se envió, con dos acciones: «Usar este mensaje» —que precarga el texto en el composer, editable, y no manda nada— y «Descartar».

Tres cosas que hay que saber para consumirlo:

  • La única marca de una sugerencia es status: "suggested". No hay ninguna clave nueva en el contrato del hilo: viaja por el mismo ThreadMessage de siempre, con id y client_message_id en null (nunca se mandó y no la escribió un operador).
  • Si el operador no hace nada, el bot la emite igual cuando la conversación vuelva a él. Ésa es la red de seguridad, y por eso «Descartar» existe: apaga el pendiente del bot, no sólo la burbuja.
  • 🔴 Si pintás el hilo con tu propio markup (con useThread en vez de ConversationThread), tenés que filtrar o marcar las sugerencias con isSuggestion. Llegan con direction: "out", así que un render ingenuo las pinta como una saliente enviada y el operador va a creer que el bot ya le habló a la persona — el opuesto exacto de la verdad. isSuggestion y SUGGESTION_STATUS salen del paquete justamente para eso.
import { isSuggestion } from '@liftitapp/wa-conversations/react';

const visibles = messages.filter((m) => !isSuggestion(m)); // o pintalas distinto, pero pintalas

Por qué es un paquete y no un módulo del LMS

Porque Felipe lo completa hacia paridad desde FlexOS (Next.js) cuando extraiga su inbox. Un directorio dentro de lms-web-app no lo puede consumir. Por eso es repo propio y se publica a npm público bajo el scope @liftitapp con access public — no un directorio del LMS ni el single-spa legacy. El único precedente de publicación de la org es @liftitapp/lms-http-client, que también vive en el registry público (@liftitapp/lms-mf-ui no es molde de esto: es private: true y se despliega a un bucket de GCS).

Regla de diseño que no se puede romper

El core (src/core/) nunca arma URLs ni conoce la autenticación: recibe un fetcher inyectado y le pasa descriptores semánticos. El único lugar del paquete autorizado a mapear descriptores→URL con auth es el default-fetcher del entry ./element (src/element/), el adaptador que traduce los atributos del Web Component (reescrito en la Fase 6 por D-01/D-02). Si el host inyecta su propio fetcher — prop en ./react, property en el elemento — el paquete no ve ni URLs ni tokens.

Eso es lo que permite que el mismo core sirva a lms-web-app, a backoffice-v2-lite y a FlexOS, cuyo backend scopea por organización en la ruta mientras el nuestro scopea por phone_number_id. Si algún componente del core recibe un orgId o un phoneNumberId como prop y construye una ruta con él, esa compatibilidad se pierde. scripts/verify-externals.mjs lo asserta en cada build.

Contexto

Setup

source ~/.nvm/nvm.sh && nvm use && pnpm install --prefer-offline

Publicar

Se publica a mano, desde tu máquina. No hay workflow de release, y no es un olvido: hubo uno basado en el Trusted Publishing de npm (OIDC desde GitHub Actions) y se sacó. Exigía un paso de registro en npmjs.com que nunca se hizo, así que los dos intentos —v0.4.0 y v0.5.0— corrieron el pipeline entero y murieron en el npm publish con ENEEDAUTH. Un pipeline que sólo puede fallar es peor que no tenerlo: entrena a ignorar el rojo. Es además lo que ya hace el otro paquete publicable de la org, lms-http-client.

source ~/.nvm/nvm.sh && nvm use
npm login                    # una vez por máquina
npm version minor            # bumpea package.json y crea el tag vX.Y.Z
npm publish                  # prepublishOnly corre los cinco gates
git push --follow-tags

prepublishOnly encadena typecheck → lint → test → build → verify:tarball, y ninguno de los cinco es decorativo. Los dos últimos son los que atrapan lo irreversible:

  • pnpm build corre publint, attw y verify-externals — que React quede afuera de dist/react/ y adentro de dist/element/, y que los dos entries tengan sus .d.ts.
  • pnpm verify:tarball asserta que no viaje nada fuera de dist/. contract/thread-v0.fixture.json lleva teléfonos y nombres reales y .planning/ es documentación interna: esto va a un registry público. También frena una prerelease que fuera a publicarse bajo latest.

⚠️ npm sólo deja despublicar dentro de las 72 horas y sin dependientes. Lo que suba es prácticamente permanente — de ahí que los gates fallen en vez de avisar.

Prereleases

Una versión con guion (1.0.0-rc.1) tiene que ir al dist-tag next, nunca a latest:

npm publish --tag next

latest es lo que se lleva quien haga npm i @liftitapp/wa-conversations sin pedir versión, así que publicar un rc ahí lo convierte en la versión por defecto de todos los consumidores. El gate lo impide y te dice el comando correcto.

Si alguna vez se vuelve a publicar desde CI

Hace falta registrar el Trusted Publisher en npmjs.com (paquete → Settings → Trusted Publisher, org Liftitapp, repo wa-conversations, el workflow que publique). Ojo con una trampa que la doc de npm no cubre: no está documentado cómo registrarlo para un paquete que todavía no existe en el registry, así que es probable que el primer publish necesite hacerse igual a mano y que el OIDC tome el relevo recién después.

Lo que no hay que hacer es meter un NPM_TOKEN en los secretos del repo. npm publish encadena prepublishOnly, así que eslint, vitest, tsc, tsdown y las transitivas de las devDeps correrían con un token de escritura al scope en process.env. Si no queda otra, que sea un token granular acotado exclusivamente a @liftitapp/wa-conversations y publicando con --ignore-scripts.