El puente entre una Cosmos App y la plataforma que la embebe.
Una app de terceros corre adentro de un iframe y necesita pedirle cosas al
anfitrión: el token de sesión, los datos de la tienda, navegar, abrirse a
pantalla completa. Eso viaja por postMessage, y postMessage es exactamente
tan seguro como el cuidado que se le ponga.
Cero dependencias. Las dos puntas en el mismo paquete.
Los puentes de este tipo se escriben una vez y se auditan nunca. Los dos errores son siempre los mismos:
// al enviar: le habla a cualquier padre que lo esté enmarcando
window.parent.postMessage(mensaje, "*");
// al recibir: nunca mira de dónde vino
window.addEventListener("message", (e) => manejadores.forEach((h) => h(e.data)));El primero filtra lo que mandás a cualquier página que logre enmarcar tu app. El segundo acepta como legítimo cualquier mensaje que alguien pueda inyectar. Juntos, en un puente que transporta tokens de sesión, son una toma de cuenta.
Este paquete hace las dos cosas imposibles en vez de desaconsejarlas:
allowedOriginses obligatorio. No hay valor por defecto, porque no existe uno seguro y adivinarlo es cómo se termina en"*".- No hay comodín. Ni al enviar ni al recibir. Una política que no nombre ningún origen concreto falla al construir el puente, no en producción.
- Todo mensaje entrante pasa por tres puertas antes de llegar a un
manejador: origen permitido, sobre reconocible, y
clientIdque coincida. Lo que no pasa se reporta poronRejecteden vez de desaparecer.
Del lado de la app, adentro del iframe:
import { createAppBridge } from "@cosmosapp/app_bridge";
const bridge = createAppBridge({
clientId: "mi-app",
allowedOrigins: "https://admin.cosmospay.lat",
});
await bridge.ready();
const { token } = await bridge.request("auth:sessionToken");
bridge.subscribe("cart:updated", (carrito) => {
render(carrito);
});Del lado de la plataforma, en la página que embebe:
import { createHostBridge } from "@cosmosapp/app_bridge";
const host = createHostBridge({
clientId: "mi-app",
allowedOrigins: "https://apps.cosmospay.lat",
frame: document.querySelector("iframe")!,
});
host.respond("auth:sessionToken", () => ({ token: emitirTokenAcotado() }));
host.send("cart:updated", carritoActual);App (createAppBridge)
send(type, payload?) |
manda y sigue, sin esperar respuesta |
request(type, payload?) |
manda y espera la respuesta, o rechaza por tiempo |
subscribe(type, handler) |
escucha, devuelve la función para dar de baja |
ready() |
resuelve cuando el anfitrión saludó de vuelta |
destroy() |
saca todos los listeners |
Anfitrión (createHostBridge): lo mismo, más respond(type, resolver) para
contestar preguntas.
Opciones
clientId |
identifica la app; viaja en cada mensaje |
allowedOrigins |
string, lista o función. Obligatorio |
timeoutMs |
cuánto espera request(). Por defecto 10000 |
onRejected |
se llama con cada mensaje descartado y por qué |
Las dos puntas viajan juntas. Un protocolo con sus mitades en repositorios distintos se desincroniza, y cuando una mitad es privada la desincronización es invisible: quien escribe apps se entera en producción.
request() correlaciona. Sin eso, dos preguntas del mismo tipo lanzadas a
la vez se resuelven cada una con la respuesta de la otra. Hay un test que lo
prueba contestando fuera de orden a propósito.
Un resolver que explota contesta igual. Si el anfitrión deja caer la
excepción, del otro lado hay un await esperando diez segundos para nada. Se
manda un tipo:error con el mismo id de correlación.
El anfitrión mira el src del frame, no sólo la política. La política dice
a quién le creemos; el src dice dónde vive este frame en concreto. Si no
coincide, no se manda nada.
ready() lo inicia el frame. El anfitrión no puede saber cuándo terminó de
cargar la app, así que la app saluda y el anfitrión contesta.
npm run typecheck tsc --noEmit, limpio
npm test 19 tests, todos en verde
Los tests corren sin navegador ni red: un window falso hecho a mano registra
cada postMessage con el targetOrigin con el que fue llamado, así la
afirmación de que nunca se usa un comodín se comprueba en vez de prometerse.
Ver LICENSE.
El resto de Cosmos es source-available. Este no, y la razón es que no es el
negocio: el negocio son los pagos, Stellar y la red de comerciantes. Un puente
de postMessage y un renderizador son infraestructura de integración.
Mantenerla cerrada no protege nada y sí cuesta: nadie audita lo que no puede leer, y en un paquete cuya única función es no dejar pasar mensajes de origen ajeno, que lo miren desde afuera es parte del producto.
Es lo mismo que hacen Shopify con su App Bridge y Stripe con stripe-js. La
capa de integración abierta empuja la adopción de lo que sí se cobra.
npm run ejemplo # panel en :8090, la app en :8091Dos puertos y no uno a propósito: para un navegador son dos orígenes distintos, que es lo que hace que la lista blanca signifique algo. Servido todo junto, la comprobación de origen se cumple sola y el ejemplo no demuestra nada.
El panel muestra a la derecha todo lo que cruza el puente, y tiene un botón que
manda un mensaje bien formado pero con el clientId de otra app, para ver el
rechazo.
Eso mismo corre como test (test/e2e.test.mjs) contra Chromium. Se saltea solo
si playwright no está instalado, así que npm test sigue andando en una máquina
sin navegador.