Ir al contenido

Plugins y hooks

Wu tiene dos mecanismos de extensión y no son lo mismo.

Los hooks son middleware. Registras una función en una de las 12 fases del ciclo de vida; se ejecuta en línea, puede mutar el contexto y puede cancelar la operación. Acceso total, sin sandbox: es tu propio código en tu propio shell.

Los plugins son extensiones empaquetadas, con aire de terceros. Declaran permisos, reciben una superficie de API congelada y limitada a esos permisos, y sus hooks están protegidos por timeout y aislados de errores, de modo que un plugin malo degrada en vez de romper la página.

Usa hooks para la lógica propia de tu shell. Usa plugins para comportamiento reutilizable y distribuible.

import { useHook } from 'wu-framework';
// o wu.hooks.use
use(phase: string, middleware: (context, next) => Promise<void>, options?: {
name?: string;
priority?: number;
failOpen?: boolean;
}): () => void

Registra middleware en una fase del ciclo de vida y devuelve una función para darlo de baja.

Opción Por defecto Significado
name autogenerado Identificador que usa remove().
priority 0 El más alto se ejecuta primero. A igual prioridad se mantiene el orden de registro.
failOpen false Si es true, un lanzamiento se registra y la cadena continúa en lugar de cancelar.
const off = wu.hooks.use('beforeMount', async (ctx, next) => {
console.log('a punto de montar', ctx.appName);
await next();
console.log('montado');
}, { priority: 10, name: 'mount-logger' });
off(); // dar de baja

Casos límite. Lanza [WuHooks] Unknown lifecycle phase: <phase> para un nombre que no esté entre los 12, y [WuHooks] Middleware must be a function en otro caso. Ninguno de los dos errores lleva un code. No hay opción once ni opción appName. priority usa ||, así que un priority: 0 explícito y NaN colapsan ambos a 0. La función de baja se puede llamar dos veces sin problema.

Fase Contexto Cancelable
beforeInit { config }
afterInit { config } No
beforeLoad { appName, containerSelector, attempt }
afterLoad { appName, containerSelector, sandbox } No
beforeMount { appName, containerSelector, sandbox, lifecycle }
afterMount { appName, containerSelector, sandbox, mountTime } No
beforeUpdate { appName, props, mounted }
afterUpdate { appName, props } No
beforeUnmount { appName, mounted }
afterUnmount { appName } No
beforeDestroy {} No
afterDestroy {} No

“Cancelable” significa que el core comprueba el resultado y aborta la operación. Cancelar una fase after* o cualquiera de las dos de destroy no tiene ningún efecto en el core: el resultado no se inspecciona.

En el camino de keep-alive (hide() / show()), las fases de montaje y desmontaje se disparan con un keepAlive: true extra en el contexto. Compruébalo si tu hook asume que esas fases significan un desmontaje real.

No llamar a next() cancela la operación:

wu.hooks.use('beforeMount', async (ctx, next) => {
if (!(await checkAuth(ctx.appName))) return; // sin next() → cancelado
await next();
});

Mutar funciona de dos formas: cambiando el objeto de contexto directamente, o pasando un parche a next(), que se fusiona de forma superficial:

wu.hooks.use('beforeUpdate', async (ctx, next) => {
await next({ props: { ...ctx.props, traceId: newId() } });
});

A los hooks se les hace await. next() es asíncrono y devuelve el resultado aguas abajo, así que puedes envolver todo el resto de la cadena en un temporizador o en un try/finally.

Un hook que lanza antes de llamar a next() cancela la operación. Antes de la v2.7 el error se registraba y la cadena continuaba, lo que significaba que una guarda de autorización que fallaba —pongamos, un checkAuth() que se topa con un error de red— dejaba montar la app igualmente. Fallar abierto en una comprobación de seguridad es el valor por defecto equivocado. (Fail-closed significa que, ante un fallo, la operación se deniega.)

// Esto ahora bloquea el montaje si checkAuth se rechaza.
wu.hooks.use('beforeMount', async (ctx, next) => {
await checkAuth(ctx.appName);
await next();
});
// Vuelve al comportamiento antiguo donde sea realmente apropiado:
wu.hooks.use('afterMount', async (ctx, next) => {
await sendTelemetry(ctx); // no rompas el montaje por la analítica
await next();
}, { failOpen: true });

Caso límite que conviene saber. El fail-closed aplica solo si el hook lanzó antes de que se llamara a next(). Un lanzamiento después de que next() haya devuelto se registra y el resultado de la cadena se mantiene: la operación no se cancela. El resultado de “cancelado por lanzamiento” lleva el error original; una cancelación silenciosa (simplemente no llamar a next()) no.

remove(phase: string, name: string): void
useMultiple(phases: string[], middleware, options?): () => void
getHooks(phase?: string): Array | Record<string, Array>
getStats(): object
cleanup(phase?: string): void

Casos límite. remove() es un no-op silencioso para una fase o un nombre desconocidos, y elimina solo la primera coincidencia. getHooks(phase) devuelve el array interno vivo, incluidas las funciones de middleware — trátalo como de solo lectura. cleanup() sin argumento reinicia las 12 fases y el registro de ejecución.

useMultiple() tiene una trampa real: nombra cada registro `${options.name}_${phase}`, así que llamarlo sin un name produce las cadenas literales "undefined_beforeMount", "undefined_afterMount", etc. Dos llamadas anónimas a useMultiple() colisionan. Pasa siempre un name.

createSimpleHook(fn)
createConditionalHook(condition, fn)
createGuardHook(shouldContinue)
createTransformHook(transformer)
createTimedHook(fn, timeout = 5000)
import { createGuardHook, createTransformHook } from 'wu-framework';
wu.hooks.use('beforeMount', createGuardHook(async (ctx) => {
return await userCanSee(ctx.appName); // falsy → cancela el montaje
}));
wu.hooks.use('beforeUpdate', createTransformHook((ctx) => ({
props: { ...ctx.props, locale: currentLocale() },
})));
Helper Comportamiento
createSimpleHook Ejecuta fn y continúa. Un lanzamiento en fn cancela (fail-closed).
createConditionalHook Ejecuta fn solo si condition es truthy. Continúa siempre en cualquier caso.
createGuardHook Continúa solo si shouldContinue devuelve algo truthy. Falsy cancela.
createTransformHook Fusiona el valor devuelto por el transformador en el contexto.
createTimedHook Compite fn contra un timeout. Siempre continúa, incluso con error o timeout — es fail-open por construcción, independientemente de la opción failOpen.
import { usePlugin, createPlugin } from 'wu-framework';
createPlugin(config: {
name: string;
permissions?: string[];
install?: (api, options) => void;
uninstall?: (api) => void;
// más cualquiera de los 8 hooks
}): Plugin
use(plugin: Plugin | ((options) => Plugin), options?: object): void
import { usePlugin, createPlugin } from 'wu-framework';
const analytics = createPlugin({
name: 'analytics',
permissions: ['events'],
install(api, options) {
this.endpoint = options.endpoint;
api.on('app:mounted', (e) => this.send('mount', e.data));
},
afterMount({ appName, mountTime }) {
this.send('timing', { appName, mountTime });
},
onError({ phase, error, appName }) {
this.send('error', { phase, appName, message: error.message });
},
uninstall(api) {
this.flush();
},
});
usePlugin(analytics, { endpoint: '/telemetry' });

También se acepta una función factoría: se la llama con options y debe devolver el objeto plugin. Las factorías no obtienen acceso al core.

Casos límite. use() devuelve undefined: no hay handle ni función de baja; usa wu.pluginSystem.uninstall(name). Instalar un nombre que ya existe registra Plugin "<name>" already installed y devuelve sin fusionar las opciones nuevas. Si install() lanza, el error se propaga fuera de use() y el plugin no queda registrado: sus hooks nunca se ejecutan.

La validación lanza ante: un plugin que no sea un objeto, un name ausente o que no sea cadena, un nombre de más de 50 caracteres, un hook declarado que no sea una función, unos permissions que no sean un array, y cualquier cadena de permiso desconocida.

createPlugin() en sí no valida nada: un nombre ausente solo aflora después, en use().

Hook Contexto
beforeInit { config }
afterInit { config }
beforeMount { appName, containerSelector }
afterMount { appName, containerSelector, mountTime }
beforeUnmount { appName }
afterUnmount { appName }
onError { phase, error, appName? }, donde phase es 'init', 'mount' o 'unmount'
onDestroy {}

install y uninstall no son hooks: no están protegidos por timeout ni aislados de errores.

Devolver exactamente false desde un hook aborta el bucle de despacho. El core respeta eso para beforeMount y beforeUnmount, cancelando la operación. Cualquier otro valor de retorno continúa.

Fíjate en la interacción con el manejo de errores: un hook que lanza se captura y se trata como undefined, es decir, “continuar”. Si quieres vetar, return false; no lances.

El valor por defecto es ['events']. El objeto de API que se entrega a install() contiene solo lo que desbloquean los permisos declarados, más una pequeña base siempre presente: version, info, getAppInfo(name), getMountedApps(), getStats().

Permiso Desbloquea
events api.emit, api.on, api.off
store api.getState, api.setState
mount api.mount, api.unmount
config api.configuresolo se aplican las claves debug y logLevel; todo lo demás se descarta silenciosamente
apps Nada. Se acepta como válido pero no hay ninguna API detrás. El listado de apps ya está en la API base.
unsafe Todo lo anterior, más api._unsafeCore — el objeto core en crudo. Registra un aviso al instalar.

Declarar permissions: [] da cero permisos, no el valor por defecto: el valor por defecto solo aplica cuando el campo está ausente.

El objeto de API está congelado en profundidad, de forma recursiva, antes de llegar al plugin. Los objetos planos y los arrays se congelan; las funciones y las instancias de clase se dejan invocables. Esa última excepción implica que api._unsafeCore no está congelado — que es justamente el sentido de unsafe, y la razón para tratarlo como último recurso.

Cada invocación de un hook de plugin compite contra un timeout de 5000 ms (configurable con new WuPluginSystem(core, { hookTimeout })).

Al agotarse el timeout, el resultado del hook pasa a ser undefined y el despacho continúa. No lanza, no cancela la operación, y no da de baja ni deshabilita el plugin. La promesa subyacente no se cancela: sigue ejecutándose en segundo plano. Lo mismo aplica a un hook que lanza: registrado, tragado, tratado como “continuar”.

La consecuencia es que los plugins son honestamente de mejor esfuerzo. Un plugin no puede romper tu página, y tampoco puede bloquear nada de forma fiable salvo devolviendo false con prontitud.

uninstall(pluginName: string): void
getPlugin(pluginName: string): Plugin | undefined
getStats(): { totalPlugins, plugins, hooks }
cleanup(): void

Casos límite. uninstall() sobre un nombre desconocido es un aviso, no un lanzamiento. Llama al uninstall() del plugin (con los errores capturados y registrados) y elimina exactamente las callbacks de hook que registró. getPlugin() devuelve el objeto plugin original, no el registro con sandbox. No hay list() ni getPlugins(): usa getStats().plugins.

  • Los plugins no pueden bloquear de forma fiable. Los lanzamientos y los timeouts se tragan y se tratan como “continuar”; solo un return false a tiempo veta, y solo para beforeMount y beforeUnmount.
  • El sandbox es una comodidad, no una frontera de seguridad. Un plugin es JavaScript ejecutándose en tu página. Puede alcanzar window, el DOM y el wu global. El modelo de permisos documenta la intención y atrapa accidentes; no contiene a un plugin hostil.
  • El permiso apps no hace nada. Valida y no concede ninguna API.
  • Los hooks son la mitad poderosa y no tienen sandbox alguno — por diseño, pero conviene recordarlo antes de aceptar uno de una dependencia.
  • useMultiple() sin un name produce identificadores que colisionan.
  • createTimedHook es fail-open independientemente de la opción failOpen, lo que lo convierte en la herramienta equivocada para una guarda.