Eventos
Mensajería de dispara-y-olvida entre apps que no saben nada la una de la otra.
La app del carrito emite cart:updated; la cabecera escucha; ninguna importa a
la otra.
import { emit, on, once, off } from 'wu-framework';// equivalentemente: wu.emit, wu.on, wu.once, wu.offemit(eventName: string, data?: any, opts?: { appName?: string; token?: string; timestamp?: number; meta?: object; history?: boolean;}): booleanDespacha un evento de forma síncrona a todos los listeners que coincidan.
Devuelve un booleano: true si el evento se entregó, false si lo rechazó
la comprobación de autorización.
| Opción | Por defecto | Significado |
|---|---|---|
appName |
'unknown' |
Quién emite. Necesario para que el modo estricto acepte un evento que no sea de sistema. |
token |
— | Token de autorización de registerApp(), o un token interno. |
timestamp |
Date.now() |
Sobrescribe la hora registrada. |
meta |
{} |
Metadatos arbitrarios que viajan en el objeto de evento. |
history |
true |
Ponlo a false para dejar el evento fuera del búfer de replay. Los listeners se ejecutan igual. |
const delivered = emit('cart:updated', { items: 3, total: 49.9 }, { appName: 'cart',});
if (!delivered) { console.warn('El evento fue rechazado, ¿está registrada esta app?');}Casos límite. emit() es completamente síncrono: cuando devuelve, todos los
listeners ya se ejecutaron. No hay opción sync ni variante asíncrona. Un
listener que lanza se captura y se registra individualmente; los demás listeners
se ejecutan igual y emit() sigue devolviendo true. El rechazo es la única
razón para un false — “sin listeners” devuelve true.
Cuando un evento se rechaza, no ocurre absolutamente nada: sin entrada en el
historial, sin taps, sin listeners, y el contador emitted no se incrementa.
El objeto de evento
Sección titulada «El objeto de evento»Los listeners reciben un argumento con exactamente seis campos:
| Campo | Tipo | Notas |
|---|---|---|
name |
string |
El nombre del evento emitido — no el patrón con el que te suscribiste. |
data |
any |
Tu segundo argumento, por referencia, nunca clonado. |
timestamp |
number |
Milisegundos epoch. |
appName |
string |
'unknown' cuando no se especifica. |
meta |
object |
{} cuando no se especifica. |
verified |
boolean |
Si appName está en la tabla de apps registradas. |
on('cart:updated', (event) => { console.log(event.name, event.data, event.appName);});No hay campo source. Ten en cuenta que verified es false para los emisores
internos del framework incluso cuando se autenticaron con un token interno
válido: solo refleja la pertenencia a la tabla de apps registradas.
Dos consecuencias de que el objeto sea compartido: todos los listeners y la
entrada del historial guardan la misma instancia, así que un listener que
mute event.data afecta a todos los listeners posteriores y al historial
registrado. Y un listener comodín no puede recuperar qué patrón coincidió, solo
el nombre concreto del evento.
on() / once() / off()
Sección titulada «on() / once() / off()»on(eventName: string, callback: (event: WuEvent) => void): () => voidonce(eventName: string, callback: (event: WuEvent) => void): () => voidoff(eventName: string, callback: Function): voidon() y once() devuelven una función para cancelar la suscripción.
const stop = on('user:login', (e) => showAvatar(e.data.userId));// más tardestop();
once('app:mounted', (e) => console.log('primer montaje:', e.data.appName));Casos límite. once() devuelve la función de baja de su wrapper
interno, lo que significa que off(eventName, tuCallbackOriginal) no
cancelará una suscripción once — conserva la función devuelta. El wrapper se
da de baja antes de invocar tu callback, así que un lanzamiento lo deja
igualmente dado de baja.
Registrar dos veces la misma referencia de función colapsa en un solo listener,
pero el contador interno de suscripciones se incrementa igual, así que
getStats().subscriptions va derivando con el tiempo. No hay validación de
callback; algo que no sea una función se guarda y lanza (capturado) en la
siguiente emisión.
Comodines
Sección titulada «Comodines»on('user:*', (e) => audit(e)); // cualquier evento de usuarioon('*:error', (e) => report(e)); // cualquier evento de erroron('*', (e) => firehose(e)); // todoEl * en un patrón de evento se expande a un .* de expresión regular:
coincide con cualquier carácter, incluidos los dos puntos, y también con la
cadena vacía.
| Patrón | Coincide con |
|---|---|
user:* |
user:login, user:a:b:c, user: |
* |
Todos los nombres de evento |
*:error |
cart:error, a:b:error |
a*b |
ab, axxxb |
No hay una semántica separada para ** — ** se expande a .*.*, que también
coincide con todo.
Esta no es la misma gramática que la del store, donde * significa
exactamente un segmento sin puntos. Se parecen y se comportan distinto; no
traslades la intuición de una a la otra.
Casos límite. El despacho de comodines recorre todos los nombres de evento
registrados en cada emisión, así que el coste escala con el número de nombres
distintos suscritos. Los patrones compilados se cachean en un mapa sin límite:
una entrada por cada patrón distinto que se haya probado. Un listener registrado
bajo un nombre que a la vez contiene * y es exactamente igual al nombre emitido
se invoca dos veces: una como listener exacto y otra como coincidencia
comodín. Los comodines se pueden desactivar del todo con
configure({ enableWildcards: false }).
handle() / request()
Sección titulada «handle() / request()»handle(channel: string, handler: (data: any) => any | Promise<any>): () => voidrequest<T = any>(channel: string, data?: any, opts?: { timeout?: number }): Promise<T>hasHandler(channel: string): booleanemit/on avisa; request/handle pregunta. Un canal tiene un solo
responsable y devuelve una respuesta a quien la pidió.
No hay import nombrado para estos tres: viven en wu.request / wu.handle /
wu.hasHandler (y en wu.eventBus.*, que es lo mismo).
// La app de usuarios atiende el canalconst baja = wu.handle('users:page', async ({ page, size }) => { const res = await fetch(`/api/users?page=${page}&size=${size}`); return res.json();});
// Cualquier otra app pregunta y esperaconst usuarios = await wu.request('users:page', { page: 3, size: 20 });
baja(); // deja de atenderPor qué no basta con emit/on
Sección titulada «Por qué no basta con emit/on»Un evento sin oyente no da error: no pasa nada, la vista se queda con su
«Cargando…» puesto y no hay una sola línea en la consola que apunte al archivo.
Para convertir una difusión en una pregunta hay que montar encima la correlación
por id, el temporizador y la desuscripción — treinta líneas cada vez.
Un canal que nadie atiende, en cambio, revienta al primer intento y te dice qué canales sí están abiertos:
[WuEventBus] nobody is handling 'users:page'. Open channels: cart:total, auth:me.Did the app that calls handle() mount?Un canal, un responsable
Sección titulada «Un canal, un responsable»Registrar un segundo handler en el mismo canal sustituye al primero y lo avisa por consola. Se sustituye en vez de rechazar porque el recargado en caliente vuelve a ejecutar el módulo que registró, y ahí negarse rompería el desarrollo.
La función de baja está acotada por identidad: tras una sustitución, la baja del handler anterior no se lleva por delante al que está atendiendo ahora.
if (!wu.hasHandler('users:page')) { wu.handle('users:page', servirUsuarios);}El handler se llama directamente
Sección titulada «El handler se llama directamente»request() no va y vuelve por el bus: invoca el handler en la misma pila de
llamadas. Las apps comparten una única instancia de wu en la misma página, así
que no hay nada que serializar, y lo que se gana es la traza: si el que responde
falla, el error llega con sus propios frames dentro en vez de un
{ ok: false, error: 'algo' } sin origen.
Consecuencia directa: un handler que lanza hace que request() rechace con
ese mismo error, sin envolverlo.
Se emiten igual dos eventos de traza para devtools y para la línea de tiempo — observabilidad sí, camino del dato no:
| Evento | Cuándo |
|---|---|
<canal>:request |
Antes de invocar al handler |
<canal>:response |
Solo si el handler resolvió; si lanza, no se emite |
Ambos se emiten con el appName interno wu-event-bus y su token, así que
sobreviven al modo estricto.
timeout
Sección titulada «timeout»const total = await wu.request('cart:total', null, { timeout: 3000 });// [WuEventBus] 'cart:total' did not answer in 3000mstimeout es solo por si quien atiende se queda colgado. El caso de «no hay
nadie» no lo necesita: se sabe al instante y se rechaza al instante. Sin
timeout (o con 0) la espera no tiene límite. El temporizador se limpia
siempre, resuelva o rechace.
Casos límite. request() es async, así que el caso «nadie atiende» es una
promesa rechazada, no un throw síncrono: necesita await o .catch() igual
que cualquier otro fallo. Un timeout agotado rechaza al que preguntó pero no
cancela al handler, que sigue corriendo hasta terminar. Los handlers no
están atados al ciclo de vida de la app: a diferencia de las capacidades, nadie
los revoca cuando la app que llamó a handle() se desmonta — guarda la función
de baja y llámala en tu unmount. wu.destroy() sí los borra todos.
Modo estricto
Sección titulada «Modo estricto»enableStrictMode(): voiddisableStrictMode(): voidconfigure(config: { strictMode?: boolean; validateOrigin?: boolean; maxHistory?: number; enableReplay?: boolean; enableWildcards?: boolean; logEvents?: boolean;}): voidEl modo estricto está desactivado por defecto, siempre. No se activa solo en
producción: no hay ninguna comprobación de NODE_ENV en el bus, precisamente
porque un bundle de navegador no puede deducir NODE_ENV de forma fiable en
tiempo de ejecución. Si lo quieres, lo pides — no lo des por hecho.
Hay dos formas equivalentes, y la primera es la recomendada porque es declarativa y corre antes de que se registre ninguna app:
// 1. En el init del shell (portable, explícito)await wu.init({ strictEvents: true, eventBus: { maxHistory: 500 }, apps: [ /* … */ ],});
// 2. Directamente sobre el bus, en cualquier momentowu.eventBus.enableStrictMode();wu.eventBus.configure({ strictMode: true, maxHistory: 500 });strictEvents: false lo apaga explícitamente; omitirlo deja el bus como esté.
eventBus acepta el mismo objeto que configure().
Cuando el modo estricto está apagado, el bus registra un aviso una sola vez de que se están emitiendo eventos sin comprobaciones de autorización.
Qué impone realmente el modo estricto
Sección titulada «Qué impone realmente el modo estricto»No es propiedad de namespace. Una app no queda confinada automáticamente a los eventos con su propio nombre como prefijo. El modo estricto controla la emisión según dos cosas:
- Identidad. El
appNamedeclarado debe haberse registrado conwu.eventBus.registerApp(), o ser un nombre interno del framework que presente su token exacto. - Permisos. La lista de permisos dada en el registro, comparada con el nombre del evento por cadena exacta o por comodín.
// El shell registra una app y le entrega un tokenconst token = wu.eventBus.registerApp('cart', { permissions: ['cart:*', 'checkout:started'],});
// La app debe presentarlo despuésemit('cart:updated', payload, { appName: 'cart', token });La lista de permisos por defecto es ['*'] — todo. El confinamiento por
namespace solo se logra registrando explícitamente con
permissions: ['cart:*'].
La emisión se rechaza (devuelve false) cuando el evento no es un evento de
sistema y además se da alguna de estas condiciones:
appNamees uno de los nombres internos (wu-ai,wu-mcp-bridge,wu-core,plugin,wu-event-bus) sin el token interno que coincida exactamente;appNameno está en la tabla de apps registradas — lo que incluye el'unknown'por defecto, así que cualquieremit()sinappNamese rechaza;- se aportó un token y no coincide con el registrado (no aportar ningún token se acepta para un nombre registrado);
- los permisos de la app no cubren el nombre del evento.
Los eventos de sistema siempre pasan
Sección titulada «Los eventos de sistema siempre pasan»Los nombres de evento que empiezan por wu:, system: o app: saltan la
autorización por completo — vengan de quien vengan, registrado o no, con token o
sin él.
emit('app:custom-signal', data); // siempre se entrega, incluso en modo estrictoSé honesto sobre lo que eso significa: es una simple comprobación con
startsWith, así que cualquier código de la página puede emitir bajo esos
prefijos y ser tratado como de confianza. El bus de eventos no es una frontera
de seguridad. El modo estricto encarece la suplantación accidental o casual;
no detiene a código que ya se ejecuta en tu página.
Historial y replay
Sección titulada «Historial y replay»replay(eventNameOrPattern: string, callback: (event: WuEvent) => void): voidclearHistory(eventNameOrPattern?: string): voidEl bus guarda los últimos 100 eventos (configurable con
configure({ maxHistory })). replay() recorre los que coincidan en orden
cronológico.
// Una app que monta tarde se pone al día con lo que se perdióreplay('cart:*', (e) => applyCartEvent(e));Casos límite. replay() invoca tu callback directa y síncronamente: no
vuelve a emitir, así que los demás listeners no se enteran. Acepta la misma
sintaxis de comodines que on(). Que no coincida nada es un no-op silencioso; un
argumento que no sea cadena lanza un TypeError.
clearHistory() sin argumento borra todo el búfer; con un nombre o patrón
elimina solo las entradas que coincidan. El historial guarda las mismas
referencias de objeto de evento que se entregaron a los listeners, así que un
evento mutado también queda mutado en el historial. Reducir maxHistory no
recorta retroactivamente: el búfer se ajusta en la siguiente emisión.
registerApp() / getInternalToken()
Sección titulada «registerApp() / getInternalToken()»registerApp(appName: string, opts?: { token?: string; permissions?: string[] }): stringunregisterApp(appName: string): voidgetInternalToken(appName: string): string | nullregisterApp() devuelve el token que debes entregar a la app. Volver a
registrar el mismo nombre sobrescribe la entrada con un token nuevo, invalidando
el anterior. unregisterApp() elimina la entrada de autorización pero deja en su
sitio los listeners de esa app.
getInternalToken() devuelve un token solo para los cinco nombres internos del
framework (wu-ai, wu-mcp-bridge, wu-core, plugin, wu-event-bus), y
null para cualquier otro. Es un método público y sin protección:
cualquiera que tenga una referencia al bus puede leer cualquier token interno. La
frontera es la accesibilidad del objeto bus, no el método.
getStats() y removeAll()
Sección titulada «getStats() y removeAll()»getStats(): { emitted: number; subscriptions: number; rejected: number; activeListeners: number; historySize: number; authorizedApps: number; listenersByEvent: Array<{ event: string, listeners: number }>;}removeAll(): voidconsole.log(wu.eventBus.getStats());Casos límite. activeListeners cuenta nombres de evento distintos, no
callbacks. removeAll() solo limpia los listeners y reinicia el contador de
suscripciones: el historial, los taps, la caché de comodines y la tabla de apps
registradas sobreviven.
Eventos del framework
Sección titulada «Eventos del framework»Los emite el propio framework, todos bajo prefijos siempre permitidos:
| Evento | Payload |
|---|---|
app:mounted |
{ appName, mountTime, attempt } |
app:unmounted |
{ appName } |
app:updated |
{ appName, props } |
app:hidden |
{ appName } |
app:shown |
{ appName, showTime } |
app:error |
{ appName, error } |
wu:capability:provided |
{ name, version, app } |
wu:capability:revoked |
{ name } |
Hay dos más que se emiten sin prefijo de sistema, autenticados con un token
interno: principal:changed ({ principal }) y access:denied
({ appName, role, required }).
Y cada wu.request() emite dos trazas más, también con token interno, bajo el
nombre del canal: <canal>:request (el payload de la petición) y
<canal>:response (la respuesta, solo si el handler resolvió). Son para
observabilidad — la respuesta no viaja por ahí. Mira
handle() / request().
Limitaciones honestas
Sección titulada «Limitaciones honestas»- No es una frontera de seguridad. Los prefijos de sistema saltan todas las comprobaciones, y cualquier código de la página puede usarlos.
- Sin garantías de entrega. Emitir antes de que exista un listener pierde el
evento;
replay()es la única forma de ponerse al día, limitada a 100 entradas. datase comparte por referencia entre todos los listeners y el historial.- La caché de comodines no tiene límite, y el despacho por comodín es O(nombres distintos suscritos) por emisión.
- Existen dos gramáticas de comodines incompatibles en el framework: esta y la del store.
