Ir al contenido

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.off
emit(eventName: string, data?: any, opts?: {
appName?: string;
token?: string;
timestamp?: number;
meta?: object;
history?: boolean;
}): boolean

Despacha 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.

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(eventName: string, callback: (event: WuEvent) => void): () => void
once(eventName: string, callback: (event: WuEvent) => void): () => void
off(eventName: string, callback: Function): void

on() y once() devuelven una función para cancelar la suscripción.

const stop = on('user:login', (e) => showAvatar(e.data.userId));
// más tarde
stop();
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.

on('user:*', (e) => audit(e)); // cualquier evento de usuario
on('*:error', (e) => report(e)); // cualquier evento de error
on('*', (e) => firehose(e)); // todo

El * 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(channel: string, handler: (data: any) => any | Promise<any>): () => void
request<T = any>(channel: string, data?: any, opts?: { timeout?: number }): Promise<T>
hasHandler(channel: string): boolean

emit/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 canal
const 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 espera
const usuarios = await wu.request('users:page', { page: 3, size: 20 });
baja(); // deja de atender

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?

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);
}

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.

const total = await wu.request('cart:total', null, { timeout: 3000 });
// [WuEventBus] 'cart:total' did not answer in 3000ms

timeout 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.

enableStrictMode(): void
disableStrictMode(): void
configure(config: {
strictMode?: boolean;
validateOrigin?: boolean;
maxHistory?: number;
enableReplay?: boolean;
enableWildcards?: boolean;
logEvents?: boolean;
}): void

El 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 momento
wu.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.

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:

  1. Identidad. El appName declarado debe haberse registrado con wu.eventBus.registerApp(), o ser un nombre interno del framework que presente su token exacto.
  2. 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 token
const token = wu.eventBus.registerApp('cart', {
permissions: ['cart:*', 'checkout:started'],
});
// La app debe presentarlo después
emit('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:

  • appName es uno de los nombres internos (wu-ai, wu-mcp-bridge, wu-core, plugin, wu-event-bus) sin el token interno que coincida exactamente;
  • appName no está en la tabla de apps registradas — lo que incluye el 'unknown' por defecto, así que cualquier emit() sin appName se 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 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 estricto

Sé 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.

replay(eventNameOrPattern: string, callback: (event: WuEvent) => void): void
clearHistory(eventNameOrPattern?: string): void

El 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(appName: string, opts?: { token?: string; permissions?: string[] }): string
unregisterApp(appName: string): void
getInternalToken(appName: string): string | null

registerApp() 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(): {
emitted: number;
subscriptions: number;
rejected: number;
activeListeners: number;
historySize: number;
authorizedApps: number;
listenersByEvent: Array<{ event: string, listeners: number }>;
}
removeAll(): void
console.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.

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().

  • 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.
  • data se 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.