@conduito/frame
v0.1.1
Published
The iframe / postMessage transport for Conduito — parent/child handshake, origin-locking, and the origin-allowlist security gate.
Readme
@conduito/frame
O transporte iframe / postMessage do Conduito. Conecta um host (o Editor) a um app embutido num
iframe cross-origin, faz o handshake, tranca a origem, reconecta a cada reload do app, e devolve um
Endpoint pronto do @conduito/core — com o gate de segurança que os wrappers de postMessage clássicos
não têm.
Filosofia: este é um carrier — o da topologia "app embutido ⇄ host", que é a era dos editores online e dos agentes de IA. O contrato (RPC, capabilities, eventos) mora no
@conduito/core; aqui só vive o postMessage, o handshake, o ciclo de vida do iframe, e a fronteira de confiança.
Instalação
pnpm add @conduito/frame # traz @conduito/core juntoUso
Host (o Editor cria e dirige o app)
import { connect } from "@conduito/frame";
const { endpoint, frame, origin, dispose } = await connect({
url: "https://appx.com/embed",
container: document.getElementById("stage")!,
onConnect: async (endpoint) => {
// a cada conexão — a primeira E depois de cada reload do app: refaça a sessão aqui
const { capabilities } = await endpoint.discover();
endpoint.notify("theme", "apply", currentTheme);
},
});
// agora é só @conduito/core:
const routes = await endpoint.request("routes", "list");
endpoint.on("appx", "saved", (e) => toast(e));
// dispose() → fecha o endpoint + remove o iframeconnect cria o iframe, faz o handshake (com retry) até o app responder, tranca a origem do app, e resolve
com a conexão. Rejeita com um ConduitoError coded — EHANDSHAKE (sem resposta) ou EURL (url
inválida); nunca lança síncrono. Depois de resolvida, a conexão continua viva através dos reloads do
app (ver "Ciclo de vida").
Host (o iframe já é seu — attach)
Quando você renderiza o iframe (o preview fica visível mesmo se o app não cooperar), attach dirige o
elemento existente — nunca o cria nem remove:
import { attach } from "@conduito/frame";
const conn = attach(frameEl, {
onConnect: (endpoint) => endpoint.notify("theme", "apply", currentTheme),
onDisconnect: (reason) => setStatus(reason), // "reload" | "dispose"
});
conn.connected; // o app está conectado agora?attach nunca desiste: pinga até o app aparecer, e pinga de novo a cada load. A origem é resolvida do
src do iframe (ou passe origin — obrigatório se o src ainda não foi setado; sem os dois, lança EURL).
App (ser dirigível — instala e abre a API)
import { listen } from "@conduito/frame";
listen({
// A FRONTEIRA: só estes hosts podem dirigir este app. Use "*" só em dev.
allowedOrigins: ["https://editor.appx.com"],
onConnect(endpoint, hostOrigin) {
endpoint.handle("routes", "list", () => router.getRoutes());
endpoint.handle("routes", "add", (r: Route) => router.add(r));
endpoint.emit("appx", "saved", { at: Date.now() });
},
});listen espera um handshake de uma origem permitida, responde, tranca, e entrega o Endpoint.
Ciclo de vida
O app embutido recarrega (F5 no canvas, location.reload(), o host manda recarregar depois de um
erro). É o gesto central de um editor, e o transporte sabe disso:
- O
Endpointé um só, pra vida inteira da conexão. O que o host registrou (handle,on,expose) é estado do host e sobrevive. O que morre com o realm é o estado do app — por issoonConnectdispara de novo a cada conexão: é ali que o host refaz a sessão (discover, push de tema, snapshot). - Request já enviado quando o app caiu rejeita na hora com
EDISCONNECTED— não trinta segundos depois comETIMEOUT. Ele pode ter executado (uma mutação); o transporte não repete. - Request ainda não enviado (feito enquanto o app está fora) espera na fila e sai na próxima
conexão, em ordem — sem
whenConnectedno consumidor. O timeout do próprio request limita a espera; se o app não voltar a tempo,ETIMEOUT. A fila tem teto (queueLimit, default 100); além dele osendfalha comEQUEUEFULL. onDisconnect("reload")avisa a queda;connecteddiz o estado atual.
API
| export | o quê |
| ------------------------------------ | --------------------------------------------------------------------------------------- |
| attach(frame, opts?): Attachment | host: dirige um iframe existente + reconecta a cada load (nunca cria/remove o iframe) |
| connect(opts): Promise<Connection> | host: cria iframe + attach + resolve na primeira conexão; dispose remove o iframe |
| listen(opts): Listener | app: aceita um host permitido + retorna { dispose } |
| resolveOrigin(url): string | a origem de uma URL |
| isAllowed(origin, policy): boolean | a checagem da allowlist |
| type OriginPolicy | readonly string[] | (origin) => boolean | "*" |
| type DisconnectReason | "reload" | "dispose" |
AttachOptions: { origin?, handshakeInterval?, queueLimit?, endpoint?, onConnect?, onDisconnect? } —
endpoint são as EndpointOptions do core (defaultTimeout, onError).
ConnectOptions: AttachOptions & { url, container?, name?, className?, maxAttempts? }.
ListenOptions: { allowedOrigins, onConnect, endpoint? }.
Attachment / Connection: { endpoint, frame, origin, connected, dispose }.
maxAttempts (default 5, a cada handshakeInterval de 500ms) vale só pra primeira conexão do
connect, e conta a partir do load do iframe — antes disso o app não tem como responder. Um app que
instala o listen sob demanda (um módulo de DEV carregado depois, num site de produção) pode passar dos
2,5s e ver o iframe removido com EHANDSHAKE: suba maxAttempts (Infinity nunca desiste). Reconexões não
têm teto. Com attach a pergunta não existe.
Segurança
O listen exige allowedOrigins — o app nunca responde a um host arbitrário. É o conserto do furo
clássico (o child do postmate responde a qualquer parent → um evil.com embutiria seu app logado e o
dirigiria). Depois de trancar, o app segue um host só: o mesmo host (mesma origem, mesma janela)
pode fazer handshake de novo e recebe HELLO de novo — idempotente, o endpoint é o mesmo; qualquer outra
origem continua ignorada. Combine com frame-ancestors (CSP) no app (quem pode embutir) e consent por
escopo. As três perguntas — origem, escopo, sessão — em docs/ARCHITECTURE.md.
Internals: docs/ARCHITECTURE.md (no repositório).
