Comunicacion entre MFEs
Tabla de contenidos
- Vision general
- Patron Browser CustomEvents
- Convenciones de nombrado de eventos
- Shell App como coordinador de eventos
- Casos de uso
- Anti-patrones
- Prevencion de memory leaks en cadenas de event handlers
- Logging y debugging de eventos
- Estrategias de testing
- Referencias
Vision general
En una arquitectura de micro frontends, los MFEs desplegados de forma independiente necesitan comunicarse entre si para ofrecer una experiencia de usuario cohesiva. Escenarios habituales que requieren comunicacion entre MFEs:
- Actualizaciones del carrito: Cuando un usuario agrega un producto en el MFE del catalogo, el MFE del header necesita actualizar el badge del carrito y el MFE de checkout debe reflejar el nuevo total.
- Navegacion: Un MFE puede necesitar disparar una navegacion a una ruta gestionada por otro MFE, sin importar la configuracion de rutas del otro.
- Contexto de usuario: Cuando el usuario cambia de organizacion o rol en el shell, cada MFE necesita refrescar sus datos para el nuevo contexto.
- Notificaciones: Cualquier MFE puede necesitar mostrar una notificacion toast, pero el sistema de notificaciones vive en el shell.
Por que Browser CustomEvents?
Usamos CustomEvent del navegador como mecanismo de comunicacion por las siguientes razones:
- Acoplamiento debil: Los MFEs publican y se suscriben a eventos sobre
window. No hay dependencia compartida en runtime, no hay libreria de estado compartida, ni acoplamiento a nivel de framework entre MFEs. - Agnostico de framework: Los CustomEvents son una API nativa del navegador. Funcionan independientemente de la version de React (la plataforma actualmente estandariza en React ^19.2.4), del framework o de la estrategia de renderizado que use cada MFE. Si un MFE se portara algun dia a Vue o Svelte, la capa de comunicacion permaneceria igual.
- Simplicidad: La superficie de API es reducida y bien conocida. Todo desarrollador web ya conoce
addEventListenerydispatchEvent. - Depurabilidad: Los eventos fluyen por el sistema de eventos estandar del navegador, lo que los hace visibles en DevTools y faciles de loguear.
Type Safety mediante contratos compartidos
Aunque los CustomEvents no tienen tipos a nivel del navegador, conseguimos type safety completo en tiempo de compilacion definiendo contratos de eventos en un paquete compartido @org/event-contracts dentro del monorepo. Este paquete contiene:
- El mapa canonico de eventos (nombre del evento a tipo del payload)
- Funciones helper tipadas para dispatch y suscripcion
- Un React hook para suscripcion ergonomica a eventos en componentes
Cada MFE depende de @org/event-contracts como dependencia de build. El paquete contiene solo tipos TypeScript y helpers ligeros de runtime -- nada de codigo de framework, gestion de estado ni logica de negocio.
Shell como coordinador de eventos
La aplicacion shell actua como coordinador central de eventos. Media la comunicacion entre MFEs:
- Escuchando eventos de solicitud (e.g.,
mfe:navigation:requested) y actuando en consecuencia - Despachando eventos de contexto (e.g.,
mfe:user:context-changed) que todos los MFEs pueden necesitar - Siendo propietaria de infraestructura transversal como el sistema de notificaciones, el router y el estado de autenticacion
Los MFEs se comunican a traves del shell en lugar de directamente entre si, lo que mantiene el grafo de dependencias limpio y predecible.
Patron Browser CustomEvents
Como funciona
El patron es directo: los MFEs publican eventos llamando a window.dispatchEvent con un CustomEvent, y se suscriben llamando a window.addEventListener sobre window.
Publicar un evento:
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: {
items: [{ id: 'sku-123', name: 'Widget', quantity: 2, price: 29.99 }],
total: 59.98,
},
})
);
Suscribirse a un evento:
const handler = (event: CustomEvent) => {
console.log('Cart updated:', event.detail);
};
window.addEventListener('mfe:cart:updated', handler as EventListener);
// Clean up when done
window.removeEventListener('mfe:cart:updated', handler as EventListener);
No hay dependencia de framework en este patron. Funciona en cualquier MFE independientemente de la version de React (^19.2.4 es el estandar actual de la plataforma), la estrategia de renderizado o incluso el framework en si. El objeto window es el bus compartido, y el sistema de eventos nativo del navegador gestiona el dispatch y la entrega.
Los eventos son sincronos en orden de dispatch, pero los publicadores deben tratarlos como fire-and-forget. El publicador no sabe quien esta escuchando, cuantos suscriptores existen ni que hacen con el payload. Esta es la propiedad clave de desacoplamiento.
Sistema de eventos con type safety
Los CustomEvents en bruto no tienen tipos. Para obtener type safety en tiempo de compilacion en todo el monorepo, definimos todos los contratos de eventos en un paquete compartido.
Definiciones de contratos de eventos
// packages/event-contracts/src/types.ts
export interface CartItem {
id: string;
name: string;
quantity: number;
price: number;
imageUrl?: string;
}
/**
* Event versioning scheme
* ─────────────────────────────────────────────────────────────────
* Every event payload carries a `_version` field that follows semver
* MAJOR notation (integer). The version is embedded in the payload
* rather than the event name so that subscribers can inspect the
* version at runtime and decide whether they can handle the event.
*
* Versioning rules:
* - Additive (new optional fields) → same version, no bump
* - Changing/removing an existing field → bump MAJOR version
* - Renaming or restructuring the payload → bump MAJOR version
*
* When bumping a MAJOR version:
* 1. Add a NEW event key to MfeEventMap (e.g., 'mfe:cart:updated:v2').
* 2. Keep the old key for a deprecation period (minimum 2 release cycles).
* 3. Publishers dispatch BOTH versions during the migration window.
* 4. Subscribers migrate to the new version.
* 5. Remove the old key after all consumers have migrated.
*
* The _version field lets runtime validators (see replayRecording)
* reject events whose schema has drifted from what the consumer expects.
*/
/** Base metadata included in every event payload. */
export interface MfeEventMeta {
/** Schema version of this event payload (integer, starts at 1). */
_version: number;
}
export interface MfeEventMap {
'mfe:cart:updated': MfeEventMeta & { items: CartItem[]; total: number };
'mfe:cart:item-added': MfeEventMeta & { item: CartItem };
'mfe:cart:cleared': MfeEventMeta & {};
'mfe:user:context-changed': MfeEventMeta & { userId: string; orgId: string; role: string };
'mfe:user:preferences-updated': MfeEventMeta & { locale: string; timezone: string };
'mfe:navigation:requested': MfeEventMeta & { path: string; params?: Record<string, string> };
'mfe:navigation:completed': MfeEventMeta & { path: string };
'mfe:notification:show': MfeEventMeta & {
message: string;
type: 'info' | 'warning' | 'error' | 'success';
duration?: number;
action?: { label: string; path: string };
};
'mfe:notification:dismiss': MfeEventMeta & { id: string };
'mfe:theme:changed': MfeEventMeta & { theme: 'light' | 'dark' };
'mfe:auth:session-expired': MfeEventMeta & {};
'mfe:auth:token-refreshed': MfeEventMeta & { expiresAt: number };
}
/**
* Current schema versions for each event. Bump the version here
* when making a breaking change to the payload shape.
*/
export const MFE_EVENT_VERSIONS: Record<keyof MfeEventMap, number> = {
'mfe:cart:updated': 1,
'mfe:cart:item-added': 1,
'mfe:cart:cleared': 1,
'mfe:user:context-changed': 1,
'mfe:user:preferences-updated': 1,
'mfe:navigation:requested': 1,
'mfe:navigation:completed': 1,
'mfe:notification:show': 1,
'mfe:notification:dismiss': 1,
'mfe:theme:changed': 1,
'mfe:auth:session-expired': 1,
'mfe:auth:token-refreshed': 1,
};
// Utility type to extract the detail type for a given event name
export type MfeEventDetail<K extends keyof MfeEventMap> = MfeEventMap[K];
// Typed CustomEvent
export type MfeCustomEvent<K extends keyof MfeEventMap> = CustomEvent<MfeEventMap[K]>;
Helpers tipados de dispatch y subscribe
// packages/event-contracts/src/dispatch.ts
import type { MfeEventMap, MfeEventDetail } from './types';
import { MFE_EVENT_VERSIONS } from './types';
/**
* Dispatch a typed MFE event on the window.
* The _version field is injected automatically from MFE_EVENT_VERSIONS,
* so callers do not need to supply it manually.
*
* @example
* dispatch('mfe:cart:updated', { items: [...], total: 59.98 });
* // detail dispatched: { _version: 1, items: [...], total: 59.98 }
*/
export function dispatch<K extends keyof MfeEventMap>(
eventName: K,
detail: Omit<MfeEventDetail<K>, '_version'>
): void {
const versionedDetail = {
...detail,
_version: MFE_EVENT_VERSIONS[eventName],
} as MfeEventDetail<K>;
const event = new CustomEvent(eventName, {
detail: versionedDetail,
bubbles: false,
cancelable: false,
});
window.dispatchEvent(event);
}
// packages/event-contracts/src/subscribe.ts
import type { MfeEventMap, MfeCustomEvent } from './types';
type MfeEventHandler<K extends keyof MfeEventMap> = (
event: MfeCustomEvent<K>
) => void;
/**
* Subscribe to a typed MFE event on the window.
* Returns an unsubscribe function for cleanup.
*
* @example
* const unsubscribe = subscribe('mfe:cart:updated', (event) => {
* console.log(event.detail.items); // fully typed
* });
*
* // Later: clean up
* unsubscribe();
*/
export function subscribe<K extends keyof MfeEventMap>(
eventName: K,
handler: MfeEventHandler<K>
): () => void {
const wrappedHandler = handler as EventListener;
window.addEventListener(eventName, wrappedHandler);
return () => {
window.removeEventListener(eventName, wrappedHandler);
};
}
React Hook para suscripcion a eventos
// packages/event-contracts/src/useMfeEvent.ts
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { MfeEventMap, MfeCustomEvent } from './types';
type MfeEventHandler<K extends keyof MfeEventMap> = (
event: MfeCustomEvent<K>
) => void;
// useLayoutEffect runs synchronously after render but before the browser
// paints, so the ref is updated before any pending events can fire.
// In SSR environments useLayoutEffect warns, so fall back to useEffect.
const useIsomorphicLayoutEffect =
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
/**
* React hook for subscribing to MFE events with automatic cleanup.
*
* The handler is stored in a ref and updated synchronously via
* useLayoutEffect, which eliminates stale closure risk: the event
* listener always invokes the latest handler even if the component
* re-renders between the event dispatch and the handler execution.
*
* Previous versions used a plain useEffect (no deps) to update the ref,
* which left a one-tick window where the ref still pointed to the
* previous handler. useLayoutEffect closes that gap because it runs
* synchronously after render, before the browser yields back to the
* event loop.
*
* You do NOT need to memoize the handler with useCallback.
*
* @example
* function CartBadge() {
* const [count, setCount] = useState(0);
*
* useMfeEvent('mfe:cart:updated', (event) => {
* setCount(event.detail.items.length);
* });
*
* return <Badge count={count} />;
* }
*/
export function useMfeEvent<K extends keyof MfeEventMap>(
eventName: K,
handler: MfeEventHandler<K>
): void {
const handlerRef = useRef(handler);
// Synchronously update the ref after every render so that the
// listener closure below always calls the latest handler.
// useLayoutEffect (not useEffect) ensures the ref is current
// before any queued events can fire in the same browser task.
useIsomorphicLayoutEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const listener = (event: Event) => {
handlerRef.current(event as MfeCustomEvent<K>);
};
window.addEventListener(eventName, listener);
return () => {
window.removeEventListener(eventName, listener);
};
}, [eventName]);
}
Barrel Export del paquete
// packages/event-contracts/src/index.ts
export type { MfeEventMap, MfeEventDetail, MfeCustomEvent, MfeEventMeta, CartItem } from './types';
export { MFE_EVENT_VERSIONS } from './types';
export { dispatch } from './dispatch';
export { subscribe } from './subscribe';
export { useMfeEvent } from './useMfeEvent';
Cada MFE importa desde @org/event-contracts. TypeScript valida que el nombre del evento y el payload coincidan en tiempo de compilacion:
import { dispatch } from '@org/event-contracts';
// Correct: TypeScript validates the payload shape
dispatch('mfe:cart:updated', { items: [], total: 0 });
// Type error: 'count' does not exist on type '{ items: CartItem[]; total: number }'
dispatch('mfe:cart:updated', { count: 5 });
// Type error: 'mfe:cart:unknown' is not a key of MfeEventMap
dispatch('mfe:cart:unknown', {});
Convenciones de nombrado de eventos
Todos los eventos MFE siguen una convencion de nombrado estricta para garantizar consistencia y descubrimiento en toda la plataforma.
Formato
mfe:{domain}:{action}
- Prefijo: Siempre
mfe:-- esto crea un namespace para nuestros eventos y evita colisiones con eventos nativos del navegador o de librerias de terceros. - Dominio: El MFE propietario o area funcional, en minusculas. Identifica quien es responsable del contrato del evento. Ejemplos:
cart,user,navigation,notification,auth,theme. - Accion: Que ocurrio o que se solicita, en minusculas con guiones para acciones de varias palabras.
- Usar pasado para eventos que describen algo que ya ocurrio:
updated,completed,cleared,session-expired. - Usar imperativo para eventos que solicitan una accion:
requested,show,dismiss.
- Usar pasado para eventos que describen algo que ya ocurrio:
Catalogo de eventos
Cada payload de evento incluye un campo _version (ver Versionado de eventos mas arriba). La columna Version muestra la version actual del schema. Se incrementa la version cuando se hacen cambios incompatibles en la forma del payload.
| Evento | Version | Publicador | Suscriptores | Descripcion |
|---|---|---|---|---|
mfe:cart:updated | 1 | Cart MFE | Header, Checkout | El contenido del carrito cambio (items agregados, eliminados o cantidad actualizada) |
mfe:cart:item-added | 1 | Cart MFE | Shell (analytics) | Se agrego un item al carrito |
mfe:cart:cleared | 1 | Cart MFE | Header, Checkout | El carrito fue vaciado |
mfe:user:context-changed | 1 | Shell | Todos los MFEs | El usuario cambio de organizacion o rol |
mfe:user:preferences-updated | 1 | Settings MFE | Shell, Todos los MFEs | El usuario actualizo sus preferencias de locale o timezone |
mfe:navigation:requested | 1 | Cualquier MFE | Shell (router) | Solicitar navegacion a una ruta gestionada por otro MFE |
mfe:navigation:completed | 1 | Shell | Cualquier MFE | La navegacion a una nueva ruta se completo |
mfe:notification:show | 1 | Cualquier MFE | Shell (notification UI) | Mostrar una notificacion toast al usuario |
mfe:notification:dismiss | 1 | Cualquier MFE | Shell (notification UI) | Descartar una notificacion especifica |
mfe:theme:changed | 1 | Shell | Todos los MFEs | El tema cambio entre light y dark |
mfe:auth:session-expired | 1 | Shell | Todos los MFEs | La sesion del usuario expiro, se requiere autenticacion |
mfe:auth:token-refreshed | 1 | Shell | Todos los MFEs | El access token fue refrescado, nuevo tiempo de expiracion |
Agregar nuevos eventos
Al agregar un nuevo evento:
- Agregar el nombre del evento y el tipo de payload a
MfeEventMapen@org/event-contracts. - Agregar una entrada en la tabla del catalogo de eventos de arriba.
- Abrir un PR -- el contrato de eventos es una API compartida y debe ser revisado por los equipos que lo publicaran o se suscribiran.
- Los consumidores actualizan su dependencia de
@org/event-contractse implementan los handlers.
Shell App como coordinador de eventos
La aplicacion shell es el coordinador central para la comunicacion entre MFEs. Cumple dos roles:
- Listener: Se suscribe a eventos de solicitud despachados por MFEs y actua sobre ellos (e.g., gestionar solicitudes de navegacion, mostrar notificaciones).
- Broadcaster: Despacha eventos de contexto que todos los MFEs pueden necesitar (e.g., cambios de contexto de usuario, cambios de tema, estado de autenticacion).
Configuracion de coordinacion de eventos del Shell
// apps/shell/src/events/setupEventCoordination.ts
import { subscribe, dispatch } from '@org/event-contracts';
import type { MfeEventDetail } from '@org/event-contracts';
interface EventCoordinationDeps {
router: {
navigate: (path: string, params?: Record<string, string>) => void;
};
notificationService: {
show: (notification: MfeEventDetail<'mfe:notification:show'>) => void;
dismiss: (id: string) => void;
};
authService: {
logout: () => void;
getSessionState: () => { isExpired: boolean };
};
}
/**
* Set up the shell's event coordination layer.
* Call this once during shell app initialization.
* Returns a cleanup function that removes all listeners.
*/
export function setupEventCoordination(
deps: EventCoordinationDeps
): () => void {
const cleanups: Array<() => void> = [];
// --- Navigation Coordination ---
// MFEs dispatch navigation requests; the shell's router handles them.
cleanups.push(
subscribe('mfe:navigation:requested', (event) => {
const { path, params } = event.detail;
deps.router.navigate(path, params);
// Notify MFEs that navigation completed
dispatch('mfe:navigation:completed', { path });
})
);
// --- Notification Coordination ---
// MFEs dispatch notification requests; the shell's notification UI renders them.
cleanups.push(
subscribe('mfe:notification:show', (event) => {
deps.notificationService.show(event.detail);
})
);
cleanups.push(
subscribe('mfe:notification:dismiss', (event) => {
deps.notificationService.dismiss(event.detail.id);
})
);
// --- Auth Coordination ---
// The shell monitors auth state and broadcasts session expiry to all MFEs.
// This is typically triggered by an HTTP 401 interceptor or token refresh failure.
// See 09-authentication.md for the full token lifecycle: the BFF Worker at
// bff.example.com handles token exchange (POST https://api.workos.com/sso/token)
// and token refresh (POST https://api.workos.com/user_management/authenticate)
// on behalf of the browser. The shell's role here is limited to detecting
// 401 responses and broadcasting the session-expired event.
// Return cleanup function
return () => {
cleanups.forEach((cleanup) => cleanup());
};
}
Inicializacion de la Shell App
// apps/shell/src/App.tsx
import { useEffect, useRef } from 'react';
import { useRouter } from './routing/useRouter';
import { useNotificationService } from './notifications/useNotificationService';
import { useAuthService } from './auth/useAuthService';
import { setupEventCoordination } from './events/setupEventCoordination';
export function App() {
const router = useRouter();
const notificationService = useNotificationService();
const authService = useAuthService();
const cleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
cleanupRef.current = setupEventCoordination({
router,
notificationService,
authService,
});
return () => {
cleanupRef.current?.();
};
}, [router, notificationService, authService]);
return (
<ShellLayout>
<NotificationContainer />
<RouterOutlet />
</ShellLayout>
);
}
Broadcast de contexto
Cuando el shell detecta un cambio de contexto (el usuario cambia de organizacion, cambia el tema, etc.), lo transmite a todos los MFEs:
// apps/shell/src/auth/useOrgSwitcher.ts
import { dispatch } from '@org/event-contracts';
export function useOrgSwitcher() {
const switchOrg = async (orgId: string) => {
// 1. Update the org on the backend
const response = await fetch('/api/auth/switch-org', {
method: 'POST',
body: JSON.stringify({ orgId }),
headers: { 'Content-Type': 'application/json' },
});
const { userId, orgId: newOrgId, role } = await response.json();
// 2. Broadcast the context change to all MFEs
dispatch('mfe:user:context-changed', {
userId,
orgId: newOrgId,
role,
});
};
return { switchOrg };
}
Reglas para el flujo de eventos
- Los MFEs despachan eventos de solicitud; el shell actua sobre ellos. Un MFE nunca debe gestionar directamente el evento de solicitud de otro MFE (e.g., solo el shell gestiona
mfe:navigation:requested). - El shell despacha eventos de contexto; los MFEs se suscriben. Los eventos de contexto como cambios de usuario, cambios de tema y estado de autenticacion siempre los emite el shell.
- Los MFEs despachan eventos de dominio; otros MFEs pueden suscribirse. Un MFE que es propietario de un dominio (e.g., Cart) despacha eventos sobre cambios de estado en ese dominio. Otros MFEs que necesitan esa informacion se suscriben directamente. El shell tambien puede suscribirse para analytics o coordinacion.
- Los MFEs NO deben suscribirse a eventos internos de otro MFE. Si un evento solo es relevante dentro de un unico MFE, no debe estar en
MfeEventMap-- usar component props, React context o gestion de estado local en su lugar.
Casos de uso
Actualizaciones del carrito
El cart MFE es propietario del dominio del carrito de compras. Cuando el contenido del carrito cambia, despacha un evento para que otros MFEs puedan reaccionar sin importar el codigo ni el estado del cart MFE.
Cart MFE: Publicando actualizaciones
// apps/cart-mfe/src/services/cartService.ts
import { dispatch } from '@org/event-contracts';
import type { CartItem } from '@org/event-contracts';
interface CartState {
items: CartItem[];
total: number;
}
let cartState: CartState = { items: [], total: 0 };
function recalculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
function broadcastCartUpdate(): void {
dispatch('mfe:cart:updated', {
items: cartState.items,
total: cartState.total,
});
}
export function addToCart(item: CartItem): void {
const existingIndex = cartState.items.findIndex((i) => i.id === item.id);
if (existingIndex >= 0) {
cartState.items[existingIndex].quantity += item.quantity;
} else {
cartState.items.push({ ...item });
}
cartState.total = recalculateTotal(cartState.items);
// Broadcast the update so Header and Checkout MFEs can react
broadcastCartUpdate();
// Also dispatch the specific item-added event for analytics
dispatch('mfe:cart:item-added', { item });
}
export function removeFromCart(itemId: string): void {
cartState.items = cartState.items.filter((i) => i.id !== itemId);
cartState.total = recalculateTotal(cartState.items);
broadcastCartUpdate();
}
export function clearCart(): void {
cartState = { items: [], total: 0 };
dispatch('mfe:cart:cleared', {});
broadcastCartUpdate();
}
Header MFE: Suscribiendose al badge del carrito
// apps/header-mfe/src/components/CartBadge.tsx
import { useState } from 'react';
import { useMfeEvent } from '@org/event-contracts';
export function CartBadge() {
const [itemCount, setItemCount] = useState(0);
useMfeEvent('mfe:cart:updated', (event) => {
const totalItems = event.detail.items.reduce(
(sum, item) => sum + item.quantity,
0
);
setItemCount(totalItems);
});
if (itemCount === 0) {
return (
<button className="cart-button" aria-label="Cart is empty">
<CartIcon />
</button>
);
}
return (
<button className="cart-button" aria-label={`Cart with ${itemCount} items`}>
<CartIcon />
<span className="cart-badge">{itemCount}</span>
</button>
);
}
Checkout MFE: Suscribiendose al total del carrito
// apps/checkout-mfe/src/components/OrderSummary.tsx
import { useState } from 'react';
import { useMfeEvent } from '@org/event-contracts';
import type { CartItem } from '@org/event-contracts';
export function OrderSummary() {
const [items, setItems] = useState<CartItem[]>([]);
const [total, setTotal] = useState(0);
useMfeEvent('mfe:cart:updated', (event) => {
setItems(event.detail.items);
setTotal(event.detail.total);
});
return (
<section className="order-summary" aria-label="Order summary">
<h2>Order Summary</h2>
<ul>
{items.map((item) => (
<li key={item.id} className="order-item">
<span>{item.name}</span>
<span>
{item.quantity} x ${item.price.toFixed(2)}
</span>
</li>
))}
</ul>
<div className="order-total">
<strong>Total: ${total.toFixed(2)}</strong>
</div>
</section>
);
}
Navegacion entre MFEs
Los MFEs necesitan enlazar a rutas gestionadas por otros MFEs sin importar la configuracion de rutas del otro. El evento mfe:navigation:requested permite que cualquier MFE solicite navegacion, y el router del shell la gestiona.
MFE despachando una solicitud de navegacion
// apps/product-mfe/src/components/ProductCard.tsx
import { dispatch } from '@org/event-contracts';
interface ProductCardProps {
productId: string;
name: string;
price: number;
imageUrl: string;
}
export function ProductCard({ productId, name, price, imageUrl }: ProductCardProps) {
const handleViewDetails = () => {
dispatch('mfe:navigation:requested', {
path: `/products/${productId}`,
});
};
const handleAddToCartAndCheckout = () => {
// First add to cart (handled within this MFE or via cart API)
// Then navigate to checkout (owned by a different MFE)
dispatch('mfe:navigation:requested', {
path: '/checkout',
params: { productId },
});
};
return (
<article className="product-card">
<img src={imageUrl} alt={name} />
<h3>{name}</h3>
<p>${price.toFixed(2)}</p>
<button onClick={handleViewDetails}>View Details</button>
<button onClick={handleAddToCartAndCheckout}>Buy Now</button>
</article>
);
}
Shell gestionando la navegacion
La capa de coordinacion de eventos del shell (mostrada anteriormente) lo gestiona asi:
// Inside setupEventCoordination (shown above)
subscribe('mfe:navigation:requested', (event) => {
const { path, params } = event.detail;
// Build the URL with query params if provided
const url = new URL(path, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
}
// Use the shell's router to navigate
deps.router.navigate(url.pathname + url.search);
// Notify MFEs that navigation completed
dispatch('mfe:navigation:completed', { path: url.pathname });
});
Helper reutilizable de navegacion
Para mayor comodidad, los MFEs pueden usar un componente helper que envuelve el dispatch del evento:
// packages/event-contracts/src/MfeLink.tsx
import type { ReactNode, MouseEvent } from 'react';
import { dispatch } from './dispatch';
interface MfeLinkProps {
to: string;
params?: Record<string, string>;
className?: string;
children: ReactNode;
}
/**
* Validates that a navigation target is a safe, relative path.
* Rejects absolute URLs and protocol-relative URLs to prevent
* open redirect vulnerabilities where an attacker could craft a
* link like <MfeLink to="https://evil.com/phish"> or
* <MfeLink to="//evil.com"> that navigates the user off-site.
*
* Allowed:
* /products/123
* /checkout?item=abc
* /settings/profile
*
* Rejected:
* https://evil.com/phish
* //evil.com
* javascript:alert(1)
* data:text/html,...
*/
function isValidInternalPath(path: string): boolean {
// Must start with a single forward slash (relative path).
// Reject protocol-relative URLs (//...), absolute URLs (http://...),
// and dangerous schemes (javascript:, data:, vbscript:, etc.).
if (!path.startsWith('/') || path.startsWith('//')) {
return false;
}
// Double-check by parsing as a URL. If the resulting origin differs
// from the current page, the path would navigate off-site.
try {
const resolved = new URL(path, window.location.origin);
return resolved.origin === window.location.origin;
} catch {
return false;
}
}
/**
* A link component for navigating between MFEs.
* Renders an anchor tag for accessibility and progressive enhancement,
* but intercepts clicks to dispatch a navigation event instead.
*
* The `to` prop is validated to ensure it is a relative path on the
* same origin. Absolute URLs, protocol-relative URLs, and dangerous
* schemes (javascript:, data:) are rejected to prevent open redirect
* vulnerabilities. If validation fails, the click is blocked and a
* warning is logged.
*/
export function MfeLink({ to, params, className, children }: MfeLinkProps) {
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
// Allow cmd+click / ctrl+click to open in new tab
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
e.preventDefault();
if (!isValidInternalPath(to)) {
if (process.env.NODE_ENV === 'development') {
console.error(
`[MfeLink] Blocked navigation to unsafe path: "${to}". ` +
'MfeLink only supports relative paths starting with "/" on the same origin.'
);
}
return;
}
dispatch('mfe:navigation:requested', { path: to, params });
};
// For the href attribute, only render safe paths. If the path is
// invalid, fall back to "#" so the anchor is still keyboard-accessible
// but does not expose the unsafe URL in the DOM.
const safeHref = isValidInternalPath(to) ? to : '#';
return (
<a href={safeHref} onClick={handleClick} className={className}>
{children}
</a>
);
}
Cambios de contexto de usuario
Cuando el usuario cambia de organizacion (habitual en SaaS B2B), cada MFE necesita refrescar sus datos para el nuevo contexto de organizacion. El shell gestiona este flujo.
Shell transmitiendo cambio de contexto
// apps/shell/src/components/OrgSwitcher.tsx
import { useState } from 'react';
import { dispatch } from '@org/event-contracts';
interface Org {
id: string;
name: string;
}
interface OrgSwitcherProps {
currentOrgId: string;
organizations: Org[];
}
export function OrgSwitcher({ currentOrgId, organizations }: OrgSwitcherProps) {
const [isLoading, setIsLoading] = useState(false);
const handleOrgChange = async (orgId: string) => {
if (orgId === currentOrgId) return;
setIsLoading(true);
try {
const response = await fetch('/api/auth/switch-org', {
method: 'POST',
body: JSON.stringify({ orgId }),
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to switch organization');
}
const { userId, orgId: newOrgId, role } = await response.json();
// Broadcast context change to all MFEs
dispatch('mfe:user:context-changed', {
userId,
orgId: newOrgId,
role,
});
} catch (error) {
dispatch('mfe:notification:show', {
message: 'Failed to switch organization. Please try again.',
type: 'error',
});
} finally {
setIsLoading(false);
}
};
return (
<select
value={currentOrgId}
onChange={(e) => handleOrgChange(e.target.value)}
disabled={isLoading}
aria-label="Switch organization"
>
{organizations.map((org) => (
<option key={org.id} value={org.id}>
{org.name}
</option>
))}
</select>
);
}
MFE reaccionando al cambio de contexto
// apps/dashboard-mfe/src/components/Dashboard.tsx
import { useState, useEffect, useCallback } from 'react';
import { useMfeEvent } from '@org/event-contracts';
interface DashboardData {
metrics: Array<{ label: string; value: number }>;
recentActivity: Array<{ id: string; description: string; timestamp: string }>;
}
export function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [orgId, setOrgId] = useState<string | null>(null);
const fetchDashboardData = useCallback(async (targetOrgId: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/dashboard?orgId=${targetOrgId}`);
const dashboardData: DashboardData = await response.json();
setData(dashboardData);
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
} finally {
setIsLoading(false);
}
}, []);
// React to org context changes broadcast by the shell
useMfeEvent('mfe:user:context-changed', (event) => {
const { orgId: newOrgId } = event.detail;
setOrgId(newOrgId);
fetchDashboardData(newOrgId);
});
// Initial data fetch
useEffect(() => {
if (orgId) {
fetchDashboardData(orgId);
}
}, [orgId, fetchDashboardData]);
if (isLoading) return <DashboardSkeleton />;
if (!data) return <EmptyState />;
return (
<div className="dashboard">
<MetricsGrid metrics={data.metrics} />
<ActivityFeed activity={data.recentActivity} />
</div>
);
}
Expiracion de sesion
Cuando la sesion del usuario expira, el shell necesita notificar a todos los MFEs para que detengan operaciones pendientes, y el shell pueda redirigir al login.
Shell detectando y transmitiendo expiracion de sesion
El monitor de sesion intercepta llamadas fetch a nivel del shell para detectar respuestas HTTP 401. NO realiza el intercambio ni el refresco de tokens -- esa responsabilidad pertenece al BFF Worker (ver 09-authentication.md para el flujo completo). El BFF Worker llama a endpoints de WorkOS (https://api.workos.com/sso/token para el intercambio inicial, https://api.workos.com/user_management/authenticate para el refresco). El interceptor del lado del shell solo observa respuestas y transmite el evento session-expired cuando el refresco del BFF ya fallo.
// apps/shell/src/auth/sessionMonitor.ts
import { dispatch } from '@org/event-contracts';
/**
* Set up an HTTP interceptor that detects 401 responses
* and broadcasts session expiry to all MFEs.
*
* IMPORTANT: The interceptor preserves AbortController / AbortSignal
* behavior. If the caller passes an AbortSignal (directly or via a
* Request object) and the signal is aborted, the original AbortError
* propagates unchanged. The interceptor does NOT catch or swallow
* abort errors — doing so would break request cancellation in MFEs
* that rely on AbortController for cleanup (e.g., React useEffect
* teardown, user-initiated cancellation, timeout signals).
*
* Returns a cleanup function that restores the original fetch.
*/
export function setupSessionMonitor(): () => void {
let hasExpired = false;
// Intercept fetch to detect 401 responses
const originalFetch = window.fetch;
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
// Extract the signal from either the Request object or the init options.
// This ensures we can check whether an error is an intentional abort.
const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
let response: Response;
try {
response = await originalFetch(input, init);
} catch (error: unknown) {
// If the request was aborted via AbortController, let the
// AbortError propagate without triggering session expiry logic.
// AbortSignal.timeout() also throws an AbortError when the
// timeout elapses — this is intentional cancellation, not a
// session problem.
if (signal?.aborted) {
throw error;
}
// DOMException with name 'AbortError' can occur even without a
// signal reference (e.g., the browser cancelled the request
// during navigation). Re-throw without side effects.
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
// Network errors (DNS failure, offline, etc.) — re-throw as-is.
throw error;
}
if (response.status === 401 && !hasExpired) {
hasExpired = true;
// Broadcast session expiry to all MFEs
dispatch('mfe:auth:session-expired', {});
// Show a notification
dispatch('mfe:notification:show', {
message: 'Your session has expired. Please log in again.',
type: 'warning',
});
// Redirect to login after a short delay so MFEs can clean up
setTimeout(() => {
window.location.href = '/login?reason=session-expired';
}, 2000);
}
return response;
};
// Return a cleanup function that restores the original fetch.
// This prevents the interceptor from leaking if the shell
// unmounts (e.g., during hot module replacement in development).
return () => {
window.fetch = originalFetch;
};
}
MFE reaccionando a la expiracion de sesion
// apps/dashboard-mfe/src/components/Dashboard.tsx
import { useState, useRef } from 'react';
import { useMfeEvent } from '@org/event-contracts';
export function Dashboard() {
const [isSessionExpired, setIsSessionExpired] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useMfeEvent('mfe:auth:session-expired', () => {
setIsSessionExpired(true);
// Abort any in-flight requests
abortControllerRef.current?.abort();
});
if (isSessionExpired) {
return (
<div className="session-expired-overlay" role="alert">
<p>Your session has expired. Redirecting to login...</p>
</div>
);
}
return <DashboardContent abortControllerRef={abortControllerRef} />;
}
Secuencia completa de expiracion de sesion
El flujo completo:
- Una llamada API de un MFE retorna HTTP 401.
- El interceptor de fetch del shell captura el 401.
- El shell despacha
mfe:auth:session-expired. - Todos los MFEs suscritos:
- Abortan requests en curso.
- Muestran un overlay de "sesion expirada" o deshabilitan elementos interactivos.
- Detienen polling u operaciones en segundo plano.
- El shell muestra una notificacion y redirige a
/logintras una breve pausa.
Anti-patrones
Evitar imports directos entre MFEs
Nunca importar codigo directamente del bundle de otro MFE.
// BAD: Importing directly from another MFE
import { cartStore } from '@cart-mfe/store';
import { CartWidget } from '@cart-mfe/components';
Esto crea una dependencia de build entre MFEs. Si el cart MFE cambia su API interna, renombra un modulo o actualiza una libreria, el MFE que importa se rompe. Esto anula la propuesta de valor principal de los micro frontends: la desplegabilidad independiente.
En su lugar, usar eventos para la comunicacion y Module Federation para compartir componentes en runtime (solo cuando se configura explicitamente como modulo expuesto).
Evitar stores Redux/estado compartidos
No compartir un store global de Redux (o Zustand, MobX, etc.) entre MFEs.
// BAD: Shared global store
const globalStore = createStore({
cart: cartReducer, // owned by Cart MFE
user: userReducer, // owned by Shell
dashboard: dashReducer, // owned by Dashboard MFE
});
Problemas de este enfoque:
- Acoplamiento fuerte: Los MFEs no pueden desplegarse de forma independiente porque todos dependen de la misma forma del store y la misma version de la libreria de gestion de estado.
- Conflictos de version: Si un MFE necesita Redux 5 y otro sigue en Redux 4, el store compartido se rompe.
- Dificultad de testing: No se puede testear el estado de un MFE de forma aislada sin configurar todo el store compartido.
- Propiedad difusa: Cuando multiples MFEs pueden leer y escribir el mismo estado, los bugs se vuelven extremadamente dificiles de rastrear.
En su lugar, cada MFE gestiona su propio estado interno (con la libreria que prefiera). El intercambio de datos entre MFEs ocurre exclusivamente via eventos. Si un MFE necesita datos del dominio de otro MFE, se suscribe al evento relevante o consulta los datos desde el BFF.
Evitar comunicacion sincrona
No construir patrones request-response sobre eventos.
// BAD: Trying to get a response from an event
dispatch('mfe:cart:get-total', {});
subscribe('mfe:cart:total-response', (event) => {
// This is fragile — what if the cart MFE hasn't loaded yet?
// What if multiple things request the total simultaneously?
const total = event.detail.total;
});
Los eventos son fire-and-forget. El publicador no sabe ni le importa quien esta escuchando. Construir semantica request-response sobre eventos lleva a:
- Race conditions cuando el suscriptor aun no se ha montado.
- Ambiguedad cuando multiples publicadores despachan la misma solicitud.
- Logica de timeout fragil intentando gestionar los casos de "sin respuesta".
En su lugar, si un MFE necesita datos de otro dominio, debe:
- Llamar a la API del BFF directamente (el BFF agrega datos de multiples servicios backend).
- Suscribirse al evento que emite actualizaciones cuando los datos cambian.
Evitar exceso de eventos
No todo cambio de estado necesita ser un evento.
Los eventos deben usarse para preocupaciones transversales -- cosas que multiples MFEs genuinamente necesitan conocer. Si solo un MFE se interesa por un cambio de estado, es una preocupacion interna y debe usar component props, React context o gestion de estado local.
Senales de exceso de eventos:
- Eventos con un solo suscriptor (probablemente deberia ser un prop o context).
- Eventos que se disparan docenas de veces por segundo (probablemente deberian ser estado interno con debouncing).
- Eventos que llevan payloads grandes (probablemente deberian ser una consulta al BFF).
- Eventos para estado de UI (e.g., "modal abierto") que solo importa dentro de un MFE.
Regla general: Si eliminas el evento y solo un MFE se rompe, no deberia ser un evento.
Prevencion de memory leaks en cadenas de event handlers
Las cadenas complejas de event handlers -- donde un handler se suscribe a eventos adicionales, despacha mas eventos o captura referencias a estructuras de datos grandes -- son una fuente habitual de memory leaks en arquitecturas de micro frontends. Dado que window es un objeto global de larga vida, los listeners adjuntos nunca son recolectados por el garbage collector hasta que se eliminan explicitamente.
Patrones comunes de memory leaks
Leak 1: Suscribirse dentro de un suscriptor sin limpieza
// BAD: Every time 'mfe:user:context-changed' fires, a NEW listener is
// added for 'mfe:cart:updated'. The old listeners are never removed.
// After 10 org switches, there are 10 orphaned cart listeners.
subscribe('mfe:user:context-changed', (event) => {
const { orgId } = event.detail;
// This creates a new listener on every invocation — memory leak!
subscribe('mfe:cart:updated', (cartEvent) => {
syncCartWithOrg(orgId, cartEvent.detail);
});
});
Solucion: Rastrear y limpiar suscripciones internas
// GOOD: Store the cleanup function and call it before re-subscribing.
let cleanupCartListener: (() => void) | null = null;
subscribe('mfe:user:context-changed', (event) => {
const { orgId } = event.detail;
// Remove the previous inner subscription before creating a new one.
cleanupCartListener?.();
cleanupCartListener = subscribe('mfe:cart:updated', (cartEvent) => {
syncCartWithOrg(orgId, cartEvent.detail);
});
});
Leak 2: Capturar objetos grandes en closures de handlers
// BAD: The handler closure captures `hugeDataSet`, preventing it from
// being garbage-collected even after the component unmounts — unless
// the listener is explicitly removed.
const hugeDataSet = await fetchLargeDataSet();
subscribe('mfe:theme:changed', () => {
reprocessData(hugeDataSet); // hugeDataSet is retained by the closure
});
Solucion: Usar un ref o referencia indirecta
// GOOD (in React): Store the data in a ref so the closure captures
// only the ref object, not the data itself.
const dataRef = useRef<LargeDataSet | null>(null);
dataRef.current = hugeDataSet;
useMfeEvent('mfe:theme:changed', () => {
if (dataRef.current) {
reprocessData(dataRef.current);
}
});
// When the component unmounts, useMfeEvent removes the listener,
// and setting dataRef.current = null (or letting the ref go out of
// scope) allows the data to be garbage-collected.
Leak 3: Olvidar llamar a la funcion de unsubscribe
// BAD: The return value (unsubscribe function) is ignored.
subscribe('mfe:notification:show', handleNotification);
// GOOD: Always store and call the cleanup function when done.
const unsubscribe = subscribe('mfe:notification:show', handleNotification);
// ... later, when the consumer is torn down:
unsubscribe();
Checklist de limpieza
Al configurar cadenas de event handlers, seguir este checklist para prevenir memory leaks:
- Siempre capturar la funcion de unsubscribe retornada por
subscribe(). Guardarla en una variable, array o ref. - En componentes React, preferir
useMfeEventsobresubscribe()directo. El hook elimina automaticamente el listener al desmontar. - Nunca suscribirse dentro de un suscriptor sin rastrear la funcion de limpieza de la suscripcion interna. Si el handler externo se ejecuta N veces, se obtienen N listeners internos huerfanos.
- En
useEffect, retornar una funcion de limpieza que llame a cada funcion de unsubscribe creada dentro de ese efecto. - Evitar capturar objetos grandes directamente en closures de handlers. Usar refs o lookups indirectos.
- En el
setupEventCoordinationdel shell, agregar cada funcion de unsubscribe al arraycleanupsy llamarlas todas en la funcion de teardown retornada. - En tests de integracion, siempre llamar a cleanup/unmount despues del test para prevenir acumulacion de listeners entre casos de test.
Deteccion de leaks
Usar el event logger (documentado mas abajo) para detectar acumulacion de listeners:
// In Chrome DevTools console: count listeners per event
getEventListeners(window);
// Look for event names with unexpectedly high listener counts.
// A single MFE should typically have 0-1 listeners per event name.
// If you see 10+ listeners for the same event, you have a leak.
Logging y debugging de eventos
Event Logger Middleware
Un event logger solo para desarrollo que intercepta todos los eventos MFE y los loguea en la consola. Es invaluable para depurar el flujo de eventos entre MFEs.
// packages/event-contracts/src/devtools/eventLogger.ts
import type { MfeEventMap } from '../types';
interface EventLogEntry {
timestamp: number;
eventName: string;
detail: unknown;
source: string;
}
const EVENT_LOG_LIMIT = 500;
const eventLog: EventLogEntry[] = [];
/**
* Attach a development-only event logger that logs all MFE events to the console.
* Call this once during shell app initialization in development mode only.
*
* Returns a cleanup function that removes all listeners.
*
* @example
* if (process.env.NODE_ENV === 'development') {
* attachEventLogger();
* }
*/
export function attachEventLogger(): () => void {
const eventNames = [
'mfe:cart:updated',
'mfe:cart:item-added',
'mfe:cart:cleared',
'mfe:user:context-changed',
'mfe:user:preferences-updated',
'mfe:navigation:requested',
'mfe:navigation:completed',
'mfe:notification:show',
'mfe:notification:dismiss',
'mfe:theme:changed',
'mfe:auth:session-expired',
'mfe:auth:token-refreshed',
] as Array<keyof MfeEventMap>;
const cleanups: Array<() => void> = [];
for (const eventName of eventNames) {
const handler = (event: Event) => {
const customEvent = event as CustomEvent;
const entry: EventLogEntry = {
timestamp: Date.now(),
eventName,
detail: customEvent.detail,
source: new Error().stack?.split('\n')[2]?.trim() ?? 'unknown',
};
eventLog.push(entry);
// Keep the log bounded
if (eventLog.length > EVENT_LOG_LIMIT) {
eventLog.shift();
}
// Pretty-print to console with grouping
console.groupCollapsed(
`%c[MFE Event] %c${eventName}`,
'color: #888; font-weight: normal;',
'color: #4CAF50; font-weight: bold;'
);
console.log('Detail:', customEvent.detail);
console.log('Timestamp:', new Date(entry.timestamp).toISOString());
console.log('Source:', entry.source);
console.groupEnd();
};
window.addEventListener(eventName, handler);
cleanups.push(() => window.removeEventListener(eventName, handler));
}
// Expose the event log on window for DevTools access
(window as any).__MFE_EVENT_LOG__ = eventLog;
// Expose helper functions on window
(window as any).__MFE_EVENTS__ = {
getLog: () => [...eventLog],
getByName: (name: string) => eventLog.filter((e) => e.eventName === name),
clear: () => {
eventLog.length = 0;
console.log('[MFE Events] Log cleared');
},
getLast: (n = 10) => eventLog.slice(-n),
};
console.log(
'%c[MFE Events] Logger attached. Use __MFE_EVENTS__.getLog() to inspect.',
'color: #4CAF50; font-weight: bold;'
);
return () => {
cleanups.forEach((cleanup) => cleanup());
delete (window as any).__MFE_EVENT_LOG__;
delete (window as any).__MFE_EVENTS__;
};
}
Habilitando el Logger en el Shell
// apps/shell/src/main.tsx
import { attachEventLogger } from '@org/event-contracts/devtools/eventLogger';
if (process.env.NODE_ENV === 'development') {
attachEventLogger();
}
Integracion con DevTools
Monitorizando CustomEvents en Chrome DevTools
Chrome DevTools puede monitorizar CustomEvents usando la utilidad de consola monitorEvents:
// In Chrome DevTools Console:
// Monitor all events on window (noisy — use sparingly)
monitorEvents(window, 'mfe:cart:updated');
// Use the exposed helper from the event logger
__MFE_EVENTS__.getLog(); // All events
__MFE_EVENTS__.getByName('mfe:cart:updated'); // Filter by name
__MFE_EVENTS__.getLast(5); // Last 5 events
__MFE_EVENTS__.clear(); // Clear the log
Trazado con Performance API
Usar la Performance API del navegador para medir el tiempo de dispatch de eventos y ejecucion de handlers:
// packages/event-contracts/src/devtools/performanceTracer.ts
import type { MfeEventMap } from '../types';
/**
* Wraps dispatch and subscribe to add Performance API marks and measures.
* Enables tracing event flow in Chrome DevTools Performance panel.
*/
export function attachPerformanceTracer(): () => void {
const cleanups: Array<() => void> = [];
const knownEvents = new Set<string>();
// Patch window.dispatchEvent to add performance marks
const originalDispatchEvent = window.dispatchEvent.bind(window);
window.dispatchEvent = function tracedDispatchEvent(event: Event): boolean {
if (event.type.startsWith('mfe:')) {
const markName = `mfe-dispatch:${event.type}:${Date.now()}`;
performance.mark(markName);
knownEvents.add(event.type);
}
return originalDispatchEvent(event);
};
cleanups.push(() => {
window.dispatchEvent = originalDispatchEvent;
});
// Expose a function to generate a performance summary
(window as any).__MFE_PERF__ = {
getSummary: () => {
const entries = performance.getEntriesByType('mark').filter(
(entry) => entry.name.startsWith('mfe-dispatch:')
);
const summary = new Map<string, number>();
for (const entry of entries) {
const eventName = entry.name.split(':').slice(1, -1).join(':');
summary.set(eventName, (summary.get(eventName) ?? 0) + 1);
}
return Object.fromEntries(summary);
},
clear: () => {
performance.clearMarks();
performance.clearMeasures();
},
};
return () => {
cleanups.forEach((cleanup) => cleanup());
delete (window as any).__MFE_PERF__;
};
}
Event Replay para testing
Grabar eventos durante una sesion de usuario y reproducirlos despues para debugging o testing automatizado.
Event Recorder
// packages/event-contracts/src/devtools/eventRecorder.ts
import type { MfeEventMap } from '../types';
interface RecordedEvent {
eventName: keyof MfeEventMap;
detail: unknown;
timestamp: number;
relativeTime: number; // ms since recording started
}
interface EventRecording {
id: string;
startedAt: number;
events: RecordedEvent[];
}
let activeRecording: EventRecording | null = null;
let recordingCleanup: (() => void) | null = null;
const ALL_MFE_EVENTS: Array<keyof MfeEventMap> = [
'mfe:cart:updated',
'mfe:cart:item-added',
'mfe:cart:cleared',
'mfe:user:context-changed',
'mfe:user:preferences-updated',
'mfe:navigation:requested',
'mfe:navigation:completed',
'mfe:notification:show',
'mfe:notification:dismiss',
'mfe:theme:changed',
'mfe:auth:session-expired',
'mfe:auth:token-refreshed',
];
/**
* Start recording all MFE events.
*/
export function startRecording(): void {
if (activeRecording) {
console.warn('[MFE Recorder] Already recording. Stop first.');
return;
}
activeRecording = {
id: `recording-${Date.now()}`,
startedAt: Date.now(),
events: [],
};
const cleanups: Array<() => void> = [];
for (const eventName of ALL_MFE_EVENTS) {
const handler = (event: Event) => {
if (!activeRecording) return;
const customEvent = event as CustomEvent;
activeRecording.events.push({
eventName,
detail: structuredClone(customEvent.detail),
timestamp: Date.now(),
relativeTime: Date.now() - activeRecording.startedAt,
});
};
window.addEventListener(eventName, handler);
cleanups.push(() => window.removeEventListener(eventName, handler));
}
recordingCleanup = () => cleanups.forEach((c) => c());
console.log(`[MFE Recorder] Recording started: ${activeRecording.id}`);
}
/**
* Stop recording and return the recorded events.
*/
export function stopRecording(): EventRecording | null {
if (!activeRecording) {
console.warn('[MFE Recorder] No active recording.');
return null;
}
recordingCleanup?.();
recordingCleanup = null;
const recording = activeRecording;
activeRecording = null;
console.log(
`[MFE Recorder] Recording stopped: ${recording.id} (${recording.events.length} events)`
);
return recording;
}
/**
* Validates that a replayed event payload matches the expected schema
* before it is dispatched. This prevents stale or corrupted recordings
* from injecting malformed data into live event handlers.
*
* Validation checks:
* 1. The event name is a recognized key in MfeEventMap.
* 2. The detail is a non-null object.
* 3. The _version field is present and is a positive integer.
* 4. Domain-specific required fields are present (shallow check).
*
* Returns an error message if validation fails, or null if the event
* is valid.
*/
function validateReplayEvent(event: RecordedEvent): string | null {
if (!ALL_MFE_EVENTS.includes(event.eventName)) {
return `Unknown event name: "${String(event.eventName)}"`;
}
if (event.detail == null || typeof event.detail !== 'object') {
return `Event "${String(event.eventName)}": detail must be a non-null object, got ${typeof event.detail}`;
}
const detail = event.detail as Record<string, unknown>;
// Every event payload must carry a _version field (integer >= 1).
if (typeof detail._version !== 'number' || detail._version < 1 || !Number.isInteger(detail._version)) {
return `Event "${String(event.eventName)}": missing or invalid _version field (expected integer >= 1, got ${JSON.stringify(detail._version)})`;
}
// Shallow required-field checks per event domain.
// These catch the most common recording corruption issues without
// duplicating the full TypeScript type definitions at runtime.
const requiredFields: Partial<Record<keyof MfeEventMap, string[]>> = {
'mfe:cart:updated': ['items', 'total'],
'mfe:cart:item-added': ['item'],
'mfe:user:context-changed': ['userId', 'orgId', 'role'],
'mfe:navigation:requested': ['path'],
'mfe:navigation:completed': ['path'],
'mfe:notification:show': ['message', 'type'],
'mfe:notification:dismiss': ['id'],
'mfe:theme:changed': ['theme'],
'mfe:auth:token-refreshed': ['expiresAt'],
};
const required = requiredFields[event.eventName];
if (required) {
for (const field of required) {
if (!(field in detail)) {
return `Event "${String(event.eventName)}": missing required field "${field}"`;
}
}
}
return null; // Valid
}
/**
* Replay a recorded sequence of events with original timing.
* Each event is validated against the expected schema before dispatch.
* Invalid events are skipped with a warning rather than halting replay.
*/
export async function replayRecording(recording: EventRecording): Promise<void> {
console.log(
`[MFE Recorder] Replaying: ${recording.id} (${recording.events.length} events)`
);
let skipped = 0;
for (let i = 0; i < recording.events.length; i++) {
const event = recording.events[i];
const previousTime = i > 0 ? recording.events[i - 1].relativeTime : 0;
const delay = event.relativeTime - previousTime;
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay));
}
// Validate the event before dispatching
const validationError = validateReplayEvent(event);
if (validationError) {
console.warn(
`[MFE Recorder] Skipping invalid event at index ${i}: ${validationError}`
);
skipped++;
continue;
}
window.dispatchEvent(
new CustomEvent(event.eventName, {
detail: event.detail,
})
);
console.log(
`[MFE Recorder] Replayed: ${event.eventName} (+${event.relativeTime}ms)`
);
}
console.log(
`[MFE Recorder] Replay complete. ${recording.events.length - skipped} dispatched, ${skipped} skipped.`
);
}
/**
* Export a recording as a JSON string for saving to a file.
*/
export function exportRecording(recording: EventRecording): string {
return JSON.stringify(recording, null, 2);
}
/**
* Import a recording from a JSON string.
*/
export function importRecording(json: string): EventRecording {
return JSON.parse(json) as EventRecording;
}
Uso en la consola de DevTools
// Start recording
__MFE_RECORDER__.start();
// ... interact with the application ...
// Stop recording and get the result
const recording = __MFE_RECORDER__.stop();
// Export to clipboard for saving
copy(JSON.stringify(recording, null, 2));
// Later: replay the recording
__MFE_RECORDER__.replay(recording);
Para exponer el recorder en window:
// apps/shell/src/main.tsx
import {
startRecording,
stopRecording,
replayRecording,
exportRecording,
importRecording,
} from '@org/event-contracts/devtools/eventRecorder';
if (process.env.NODE_ENV === 'development') {
(window as any).__MFE_RECORDER__ = {
start: startRecording,
stop: stopRecording,
replay: replayRecording,
export: exportRecording,
import: importRecording,
};
}
Estrategias de testing
Tests unitarios de dispatch de eventos
Verificar que una funcion despacha el evento correcto con el payload correcto.
// apps/cart-mfe/src/__tests__/cartService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { addToCart, clearCart } from '../services/cartService';
describe('cartService', () => {
let dispatchedEvents: Array<{ type: string; detail: unknown }>;
beforeEach(() => {
dispatchedEvents = [];
// Spy on window.dispatchEvent to capture dispatched events
vi.spyOn(window, 'dispatchEvent').mockImplementation((event: Event) => {
const customEvent = event as CustomEvent;
dispatchedEvents.push({
type: customEvent.type,
detail: customEvent.detail,
});
return true;
});
});
it('dispatches mfe:cart:updated when an item is added', () => {
addToCart({
id: 'sku-123',
name: 'Widget',
quantity: 1,
price: 29.99,
});
const cartUpdatedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:updated'
);
expect(cartUpdatedEvent).toBeDefined();
expect(cartUpdatedEvent?.detail).toEqual({
items: [{ id: 'sku-123', name: 'Widget', quantity: 1, price: 29.99 }],
total: 29.99,
});
});
it('dispatches mfe:cart:item-added with the added item', () => {
const item = { id: 'sku-456', name: 'Gadget', quantity: 2, price: 15.0 };
addToCart(item);
const itemAddedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:item-added'
);
expect(itemAddedEvent).toBeDefined();
expect(itemAddedEvent?.detail).toEqual({ item });
});
it('dispatches mfe:cart:cleared when cart is cleared', () => {
addToCart({ id: 'sku-123', name: 'Widget', quantity: 1, price: 29.99 });
dispatchedEvents = [];
clearCart();
const clearedEvent = dispatchedEvents.find(
(e) => e.type === 'mfe:cart:cleared'
);
expect(clearedEvent).toBeDefined();
});
});
Tests unitarios de suscripcion a eventos en componentes React
Verificar que un componente React reacciona correctamente a un evento MFE.
// apps/header-mfe/src/__tests__/CartBadge.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, act } from '@testing-library/react';
import { CartBadge } from '../components/CartBadge';
// Helper to dispatch a typed MFE event in tests
function dispatchMfeEvent<T>(eventName: string, detail: T): void {
window.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
describe('CartBadge', () => {
it('renders with zero count initially', () => {
render(<CartBadge />);
expect(screen.getByLabelText('Cart is empty')).toBeInTheDocument();
});
it('updates badge count when mfe:cart:updated is dispatched', () => {
render(<CartBadge />);
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [
{ id: 'sku-1', name: 'Widget', quantity: 2, price: 10 },
{ id: 'sku-2', name: 'Gadget', quantity: 1, price: 20 },
],
total: 40,
});
});
expect(screen.getByLabelText('Cart with 3 items')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('returns to empty state when cart is cleared', () => {
render(<CartBadge />);
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [{ id: 'sku-1', name: 'Widget', quantity: 1, price: 10 }],
total: 10,
});
});
expect(screen.getByText('1')).toBeInTheDocument();
act(() => {
dispatchMfeEvent('mfe:cart:updated', {
items: [],
total: 0,
});
});
expect(screen.getByLabelText('Cart is empty')).toBeInTheDocument();
});
});
Testing del hook useMfeEvent
Testear el hook de forma aislada para verificar su comportamiento de suscripcion y limpieza.
// packages/event-contracts/src/__tests__/useMfeEvent.test.ts
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMfeEvent } from '../useMfeEvent';
describe('useMfeEvent', () => {
it('calls handler when the event is dispatched', () => {
const handler = vi.fn();
renderHook(() => useMfeEvent('mfe:cart:updated', handler));
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
detail: { items: [], total: 0 },
})
);
});
it('cleans up the listener on unmount', () => {
const handler = vi.fn();
const { unmount } = renderHook(() =>
useMfeEvent('mfe:cart:updated', handler)
);
unmount();
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
expect(handler).not.toHaveBeenCalled();
});
it('always uses the latest handler without re-subscribing', () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
const { rerender } = renderHook(
({ handler }) => useMfeEvent('mfe:cart:updated', handler),
{ initialProps: { handler: handler1 } }
);
// Update the handler
rerender({ handler: handler2 });
act(() => {
window.dispatchEvent(
new CustomEvent('mfe:cart:updated', {
detail: { items: [], total: 0 },
})
);
});
// Only the latest handler should be called
expect(handler1).not.toHaveBeenCalled();
expect(handler2).toHaveBeenCalledTimes(1);
});
});
Tests de integracion: Cumplimiento de contratos de eventos
Verificar que publicadores y suscriptores coinciden en el contrato del evento. Este test se ejecuta como parte de CI para detectar desviaciones en los contratos.
// packages/event-contracts/src/__tests__/contractCompliance.test.ts
import { describe, it, expect, vi } from 'vitest';
import { dispatch, subscribe } from '../index';
import type { MfeEventMap, MfeEventDetail } from '../types';
/**
* Helper that tests a full publish-subscribe cycle for a given event.
* Ensures the types flow correctly from dispatch to handler.
*/
function testEventContract<K extends keyof MfeEventMap>(
eventName: K,
payload: MfeEventDetail<K>
): void {
const handler = vi.fn();
const unsubscribe = subscribe(eventName, handler);
dispatch(eventName, payload);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
type: eventName,
detail: payload,
})
);
unsubscribe();
// Verify cleanup: handler should not be called after unsubscribe
dispatch(eventName, payload);
expect(handler).toHaveBeenCalledTimes(1);
}
describe('Event Contract Compliance', () => {
it('mfe:cart:updated contract', () => {
testEventContract('mfe:cart:updated', {
items: [{ id: 'sku-1', name: 'Widget', quantity: 1, price: 9.99 }],
total: 9.99,
});
});
it('mfe:user:context-changed contract', () => {
testEventContract('mfe:user:context-changed', {
userId: 'user-123',
orgId: 'org-456',
role: 'admin',
});
});
it('mfe:navigation:requested contract', () => {
testEventContract('mfe:navigation:requested', {
path: '/products/123',
params: { tab: 'reviews' },
});
});
it('mfe:notification:show contract', () => {
testEventContract('mfe:notification:show', {
message: 'Item added to cart',
type: 'success',
duration: 3000,
});
});
it('mfe:theme:changed contract', () => {
testEventContract('mfe:theme:changed', {
theme: 'dark',
});
});
it('mfe:auth:session-expired contract', () => {
testEventContract('mfe:auth:session-expired', {});
});
});
Paquete de utilidades de test
Proporcionar una utilidad de testing compartida que los equipos de MFE pueden usar en sus suites de tests:
// packages/event-contracts/src/testing.ts
import type { MfeEventMap, MfeEventDetail } from './types';
/**
* Dispatch a typed MFE event for use in tests.
* Wraps the event dispatch in a way that works with React Testing Library's act().
*/
export function dispatchTestEvent<K extends keyof MfeEventMap>(
eventName: K,
detail: MfeEventDetail<K>
): void {
window.dispatchEvent(
new CustomEvent(eventName, { detail })
);
}
/**
* Create a spy that records all dispatches of a specific MFE event.
* Returns an object with the spy function and a cleanup function.
*
* @example
* const { spy, cleanup } = createEventSpy('mfe:cart:updated');
* // ... trigger some action ...
* expect(spy).toHaveBeenCalledWith(expect.objectContaining({
* detail: { items: [], total: 0 }
* }));
* cleanup();
*/
export function createEventSpy<K extends keyof MfeEventMap>(
eventName: K
): {
spy: ReturnType<typeof import('vitest').vi.fn>;
cleanup: () => void;
} {
const { vi } = await import('vitest');
const spy = vi.fn();
const handler = (event: Event) => {
spy(event as CustomEvent);
};
window.addEventListener(eventName, handler);
return {
spy,
cleanup: () => window.removeEventListener(eventName, handler),
};
}
/**
* Wait for a specific MFE event to be dispatched.
* Useful for testing async flows that eventually dispatch an event.
*
* @example
* const eventPromise = waitForEvent('mfe:cart:updated');
* triggerAsyncCartUpdate();
* const event = await eventPromise;
* expect(event.detail.total).toBe(29.99);
*/
export function waitForEvent<K extends keyof MfeEventMap>(
eventName: K,
timeoutMs = 5000
): Promise<CustomEvent<MfeEventDetail<K>>> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
window.removeEventListener(eventName, handler);
reject(new Error(`Timed out waiting for event: ${eventName}`));
}, timeoutMs);
const handler = (event: Event) => {
clearTimeout(timeout);
window.removeEventListener(eventName, handler);
resolve(event as CustomEvent<MfeEventDetail<K>>);
};
window.addEventListener(eventName, handler);
});
}
Referencias
- MDN: CustomEvent -- Referencia de la API del navegador para construccion y dispatch de CustomEvent.
- MDN: EventTarget.dispatchEvent() -- Como se despachan y entregan los eventos.
- MDN: EventTarget.addEventListener() -- API de suscripcion y opciones (capture, passive, once).
- Martin Fowler: Event-Driven Architecture -- Patrones fundacionales para sistemas event-driven.
- Micro Frontends -- Vision general de patrones de arquitectura micro frontend.
- Module Federation Documentation -- Comparticion de modulos en runtime usada junto al sistema de eventos para component federation.