Ir al contenido

Contratos de capacidad

El bus de eventos es dispara-y-olvida y el store es una pizarra compartida. Ninguno de los dos permite que una app le pida a otra que haga algo y reciba una respuesta. Los contratos de capacidad sí: una app publica una implementación bajo un nombre y una versión semver, y otras apps la consumen contra un rango de versiones.

La decisión de diseño que los hace usables es que consume() nunca bloquea y nunca lanza. Devuelve un proxy de inmediato, y el proxy resuelve el proveedor en cada acceso. Consume antes de que el proveedor monte, y la capacidad empieza a funcionar en el momento en que lo haga.

import { wu, provide, consume } from 'wu-framework';
provide(name: string, impl: object | Function, opts?: {
version?: string;
shape?: string[] | Record<string, string>;
app?: string;
}): { revoke: () => boolean }

Publica una implementación bajo name.

Opción Tipo Por defecto Significado
version string '0.0.0' Versión semver contra la que los consumidores comparan su rango.
shape string[] o Record<string, string> null Comprobación en tiempo de ejecución de que la implementación tiene realmente esos miembros, verificada en el momento del provide().
app string null App propietaria. La capacidad se revoca automáticamente cuando esa app se desmonta.
wu.provide('auth', {
getUser: () => currentUser,
hasRole: (role) => currentUser?.roles.includes(role),
logout: async () => { await api.logout(); currentUser = null; },
}, {
version: '2.1.0',
app: 'auth-app',
shape: { getUser: 'function', hasRole: 'function', logout: 'function' },
});

La forma de array de shape solo comprueba la presencia; la forma de objeto asocia cada clave a una cadena de typeof y también comprueba eso.

Casos límite. provide() lanza en tres casos: un name ausente o que no sea cadena, un impl que no sea ni objeto ni función (null y los primitivos se rechazan — ojo, los arrays pasan, ya que typeof [] === 'object'), y una violación de la forma, cuyo mensaje enumera todos los problemas (getUser (missing), hasRole (expected function, got string)).

Hay exactamente un proveedor por nombre. El registro se indexa solo por nombre, así que un segundo provide('auth', ...) sustituye al primero silenciosamente: gana la última escritura, sin error, sin multiplexado de versiones. Si la versión nueva ya no satisface el rango de un consumidor vivo, ese consumidor empieza a lanzar en su siguiente acceso a una propiedad. Esto es deliberado (la re-resolución sigue viva), pero significa que no puedes ejecutar dos versiones de una capacidad en paralelo.

El handle devuelto está acotado por identidad: revoke() devuelve false y no hace nada si alguien ha vuelto a proveer ese nombre desde entonces, de modo que un handle obsoleto no puede matar al propietario actual.

consume<T>(name: string, range?: string, opts?: { wait?: false; timeout?: number }): T
consume<T>(name: string, range: string, opts: { wait: true; timeout?: number }): Promise<T>

Devuelve un proxy vivo para una capacidad. range es '*' por defecto (cualquier versión).

Opción Tipo Por defecto Significado
wait boolean false Devolver una Promise que se resuelve cuando aparezca un proveedor que satisfaga el rango, en lugar de un proxy.
timeout number (ms) ninguno Solo con wait: true. Se rechaza pasado ese tiempo. 0 cuenta como “sin timeout”.
const auth = wu.consume('auth', '^2.0.0');
// Todavía no ha pasado nada. Sin proveedor necesario, sin error.
// La búsqueda ocurre aquí, en el acceso:
if (auth.__wuAvailable) {
const user = auth.getUser();
}

Casos límite — consume() en sí nunca lanza. El proxy sí, en el primer acceso a una propiedad, con uno de estos dos mensajes:

Situación Se lanza en el acceso
No hay proveedor registrado con ese nombre [WuContracts] no provider for capability '<name>'. Did the providing app mount and call wu.provide('<name>', …)?
El proveedor existe pero su versión está fuera de tu rango [WuContracts] capability '<name>@<version>' does not satisfy '<range>'.

Ninguno de los dos errores lleva una propiedad code.

Importan más que los dos métodos de arriba, porque el proxy es lo que realmente tienes en la mano.

No es thenable. proxy.then devuelve undefined, siempre. Sin eso, un await consume('auth') le entregaría a await una función llamada then y se colgaría para siempre. El coste es que un método de capacidad literalmente llamado then queda inalcanzable a través del proxy — un intercambio aceptable, y algo que conviene saber.

Los métodos están vinculados y memoizados. proxy.fn === proxy.fn se cumple, que es lo que hace funcionar las idas y venidas de addEventListener/removeEventListener, las claves de Set y los arrays de dependencias de React. La caché se indexa por la función fuente sin vincular, así que cambiar de proveedor produce identidades nuevas y la re-resolución sigue viva.

Las escrituras son un no-op. Las capacidades consumidas son de solo lectura. Una escritura registra [WuContracts] ignored write to '<prop>' on consumed capability '<name>': consumed capabilities are read-only. y se descarta. La trampa devuelve true, así que no lanza ni siquiera en modo estricto — un no-op honesto, mejor que dejar que un consumidor mute la implementación compartida del proveedor (o le reapunte el prototipo vía __proto__).

El proxy no enumera. No hay trampa ownKeys, así que Object.keys(proxy) es [], { ...proxy } es {} y JSON.stringify(proxy) es {}. Hacer spread de una capacidad consumida pierde todo silenciosamente. Llama a los miembros directamente.

Los símbolos nunca lanzan. Las sondas de Symbol.iterator, Symbol.toStringTag y util.inspect devuelven undefined cuando no hay proveedor que satisfaga el rango, así que registrar en el log o desestructurar una capacidad ausente no revienta en un sitio inesperado.

Tres claves de introspección siempre son seguras de leer:

Clave Devuelve
__wuAvailable boolean — ¿hay ahora mismo un proveedor que satisfaga el rango?
__wuCapability El nombre de la capacidad.
__wuRange El rango con el que se creó este proxy.
const analytics = wu.consume('analytics', '^1.0.0');
// La sonda segura: no hace falta try/catch.
if (analytics.__wuAvailable) {
analytics.track('checkout', { total });
}
try {
const auth = await wu.consume('auth', '^2.0.0', { wait: true, timeout: 5000 });
render(auth.getUser());
} catch (err) {
// [WuContracts] timed out after 5000ms waiting for 'auth' satisfying '^2.0.0'.
}

Casos límite. Con wait: true y sin timeout, la promesa puede no resolverse nunca: lo único que la rechazará es wu.destroy(), con [WuContracts] framework destroyed. Pasa siempre un timeout en rutas de producción. Si ya existe un proveedor que satisface el rango, la promesa se resuelve de inmediato con el mismo proxy que habría devuelto la forma síncrona.

has(name: string, range?: string): boolean

Comprobación de disponibilidad que no lanza, equivalente a leer __wuAvailable en un proxy.

if (wu.contracts.has('payments', '>=3.0.0')) {
showCheckoutButton();
}
revoke(name: string): boolean
revokeByApp(app: string): void

revoke() elimina la capacidad y emite wu:capability:revoked. Devuelve false si no había nada registrado con ese nombre — un no-op, no un error.

const handle = wu.provide('auth', impl, { version: '2.1.0', app: 'auth-app' });
handle.revoke(); // acotado: solo revoca si sigue siendo el propietario actual
wu.contracts.revoke('auth'); // sin acotar: revoca a quien tenga el nombre ahora

revokeByApp(app) revoca todas las capacidades cuya opción app coincida. Normalmente no lo llamas tú: el framework lo llama durante el desmontaje real, usando el nombre de app que verificó él mismo en lugar de uno leído de un evento del bus, que es por lo que el sistema de contratos deliberadamente no se suscribe a app:unmounted (un evento falsificado podría, si no, revocar las capacidades de otra app).

list(): Array<{ name: string; version: string; app: string | null }>

Todo lo que se provee ahora mismo. Fíjate en que omite impl deliberadamente: esto alimenta a wu.inspect() y al overlay de devtools, no es una forma de esquivar el proxy.

console.table(wu.contracts.list());
// name version app
// auth 2.1.0 auth-app
// analytics 1.4.2 null

La comparación usa una implementación pequeña de semver sin dependencias, no node-semver. Sintaxis soportada:

Forma Ejemplo Coincide con
Cualquiera *, '', omitido Todo
Exacta 2.1.0 Solo esa versión
Rango X 1, 1.2, 1.x Cualquier parche/minor bajo el prefijo dado
Caret ^2.1.0 >=2.1.0 <3.0.0
Tilde ~2.1.0 >=2.1.0 <2.2.0
Comparadores >=2.0.0, <3.0.0, >1.0.0 Tal cual se escriben
AND >=2.0.0 <3.0.0 Separados por espacios, deben cumplirse todos
OR ^1.0.0 || ^2.0.0 Cualquiera de los dos lados

Los metadatos de prerelease y de build se eliminan antes de comparar: 2.1.0-rc.1 se trata como 2.1.0. Si dependes de la semántica de exclusión de prereleases de node-semver, aquí no la vas a tener. Un rango mal formado (un operador colgando) no coincide con nada.

La dirección es siempre: la versión del proveedor contrastada con el rango del consumidor.

Evento Payload
wu:capability:provided { name, version, app }
wu:capability:revoked { name }

Ambos se emiten con appName: 'wu-core' y pasan el modo estricto.

  • Un proveedor por nombre. Sin versiones en paralelo, sin cadena de fallback. Un re-provide con una versión incompatible rompe a los consumidores vivos en su siguiente acceso, no en el momento del provide.
  • Sin enumeración. Todo lo que dependa de iterar las claves del objeto —spread, JSON.stringify, la mayoría de utilidades de igualdad profunda— ve un objeto vacío.
  • then es inalcanzable como nombre de miembro de una capacidad.
  • La comprobación de forma es superficial. Verifica presencia y typeof, y solo en el momento del provide. No puede verificar tipos de argumentos ni de retorno, y un valor de shape que no sea ni array ni objeto pasa de forma vacua.
  • Esto no es una frontera de seguridad. Cualquier código de la página puede llamar a wu.consume(). Los contratos te dan versionado y fallo ruidoso, no control de acceso.