conduito
v0.2.0
Published
Conduito — a cooperative frontend API protocol over postMessage. The flagship: @conduito/core (the protocol) + @conduito/frame (iframe), with @conduito/port (Web Worker / MessagePort) at conduito/port.
Readme
conduito
Um protocolo de cooperação para frontends sobre postMessage: um contrato tipado, baseado em
capabilities, entre um host (um Editor) e um app embutido — pra introspectar, gerir e dirigir os
recursos do app de forma cooperativa, não hackeada.
A tese, em uma frase: assim como o backend expõe uma API, o frontend também pode — só que declarada pelo próprio app, descoberta pelo editor, e segura por contrato.
Diferente de Playwright/CDP, que dirigem de fora e adivinham a semântica do DOM, o conduito coopera de dentro: o app declara o que expõe, e então o editor — e uma IA — sabem o que cada recurso significa.
Este é o flagship — um único install com o protocolo e os dois carriers:
@conduito/core— o protocolo (envelope,EndpointRPC bidirecional + pub/sub, cancelamento, capabilities +discover, cliente tipado). Transport-agnóstico, zero-dep.@conduito/frame— o transporte iframe/postMessage (connect/attach/listen, handshake, reconexão, o gate de segurança).@conduito/port— o transporte MessagePort (Web/Shared Worker,MessageChannel,worker_threads), emconduito/port.
import { defineCapability, connect } from "conduito"; // protocolo + iframe
import { connect as connectWorker } from "conduito/port"; // o mesmo protocolo, outro carrierpnpm add conduitoA capability (o contrato dos dois lados)
Uma capability é um domínio que o app expõe: métodos chamáveis + eventos. Declare uma vez, num módulo que os dois lados enxergam:
// app/capabilities.ts — mora no APP (os handlers são dele); o host importa só o tipo
import { defineCapability } from "conduito";
export const routesCap = defineCapability({
name: "routes",
version: "1.0.0",
methods: {
list: (): Route[] => router.getRoutes(),
add: (route: Route) => router.add(route),
},
events: ["changed"],
meta: { title: "Rotas da aplicação" },
});App (ser dirigível — estilo Express)
import { listen } from "conduito";
import { routesCap } from "./capabilities";
listen({
allowedOrigins: ["https://editor.appx.com"], // a fronteira: só estes hosts dirigem
onConnect(app, hostOrigin) {
app.expose(routesCap); // registra no manifest E fia os handlers
app.emit("routes", "changed", router.getRoutes());
},
});expose faz as duas coisas de uma vez: entra no manifest (pra ser descoberto) e conecta cada método como
handler. Um unexpose devolvido desfaz as duas.
Host (dirigir o app)
import { connect, createClient } from "conduito";
import type { routesCap } from "./capabilities"; // só o TIPO — ver o aviso abaixo
const { endpoint } = await connect({
url: "https://appx.com/embed",
container: document.getElementById("stage")!,
onConnect: async (endpoint) => {
// roda na primeira conexão E a cada reload do app — refaça a sessão aqui
const { capabilities } = await endpoint.discover(); // o "db pull"
renderSidebar(capabilities);
},
});
// sem tipos: a chamada crua, quando você não tem (ou não quer) a declaração
const raw = await endpoint.request("routes", "list"); // unknown
// com tipos: payload, retorno e nomes de evento saem da declaração do app
const routes = createClient<typeof routesCap>(endpoint, "routes");
const list = await routes.request.list(); // Route[]
routes.notify.add({ path: "/novo" }); // fire-and-forget, assinatura checada
const off = routes.on.changed((r) => refresh(r)); // só os eventos declarados existem[!WARNING] Importe a declaração no host com
import type. Um import normal funciona e não levanta erro nenhum — mas arrasta os handlers junto, e com eles orouter, o store e o que mais o app usar, pro bundle do editor. O contrato é o tipo; o código fica no app.
O discover é o retrato de um instante, e o app pode abrir ou fechar domínios enquanto roda. Pra um editor
que renderiza UI a partir do manifest, assine o evento que o próprio protocolo publica a cada
expose/unexpose:
import { META_NS } from "conduito";
endpoint.on(META_NS, "capabilities", ({ capabilities }) =>
renderSidebar(capabilities),
);O app recarregou — e a conexão continua
O gesto central de um editor é "recarrega o canvas sem perder o editor". O transporte sabe disso: o
Endpoint é um só pra vida inteira da conexão, e o que o host registrou (handle, on, expose)
sobrevive ao reload do app.
onConnectdispara de novo a cada reconexão — é ali que o host refaz a sessão do lado do app (discover, push de tema, pedir o snapshot).- Um request já enviado quando o app caiu rejeita na hora com
EDISCONNECTED, não com umETIMEOUTtrinta segundos depois. Ele pode ter executado uma mutação; o transporte não repete. - Um request feito enquanto o app está fora espera na fila e sai na reconexão, em ordem — o timeout dele continua valendo como limite da espera.
Quando o iframe é seu (você renderiza, o preview fica visível mesmo se o app não cooperar), troque
connect por attach(frameEl, opts): mesma coisa, sem criar nem remover o elemento.
import { attach } from "conduito";
const conn = attach(document.querySelector("iframe")!, {
onConnect: (endpoint) => endpoint.notify("theme", "apply", currentTheme),
onDisconnect: (reason) => setStatus(reason), // "reload" | "dispose"
});
conn.connected; // o app está conectado agora?O outro carrier: worker
O contrato não muda quando o carrier muda. A mesma capability, o mesmo Endpoint e o mesmo cliente tipado
atravessam uma thread em vez de uma origem — só o jeito de abrir a conexão é outro:
// main thread
import { connect } from "conduito/port";
const { endpoint } = connect(
new Worker(new URL("./app.js", import.meta.url), { type: "module" }),
{
onConnect: (endpoint) => endpoint.discover(),
},
);
const routes = await endpoint.request("routes", "list"); // idêntico ao iframe// dentro do worker
import { listen } from "conduito/port";
import { routesCap } from "./capabilities";
listen({ onConnect: (app) => app.expose(routesCap) }); // a MESMA declaraçãoServe Web Worker, SharedWorker, MessageChannel e o worker_threads do Node — o pacote não importa
nada de runtime, então o mesmo código roda nos dois mundos. Worker não recarrega, morre: rebind(novaPorta)
aponta a conexão pro substituto mantendo o mesmo Endpoint, e transfer(payload) move um buffer em vez de
copiá-lo.
connect/listen existem nos dois transportes, com assinaturas e garantias diferentes — é por isso que
o worker mora num subpath em vez de entrar no mesmo barril. A diferença que mais importa está na seção
seguinte: o iframe tem origem pra checar, o worker não.
Erros e cancelamento
Todo erro que o conduito emite é um ConduitoError com .code estável — nunca uma string pra casar por
regex:
import { isConduitoError } from "conduito";
try {
await endpoint.request("routes", "list");
} catch (e) {
if (isConduitoError(e) && e.code === "ENOHANDLER") offerInstall();
}ENOHANDLER · EHANDLER · ETIMEOUT · EABORTED · EDISCONNECTED · EDISPOSED · ESEND · EREMOTE ·
ERESERVED · EQUEUEFULL · EHANDSHAKE · EURL · EPORT · ETRANSFER. Um handler pode lançar um
ConduitoError com code próprio, e ele cruza o wire.
Trabalho longo é cancelável dos dois lados: o host passa um AbortSignal, e o handler do app recebe o
signal correspondente — que também dispara se o request expirar ou o par sumir.
// host — o usuário mudou de ideia: o app para de trabalhar, o await rejeita EABORTED
const ctrl = new AbortController();
const building = endpoint.request("reports", "build", query, {
signal: ctrl.signal,
});
cancelButton.onclick = () => ctrl.abort();
// app — o signal dispara pelo abort do host, pelo timeout do request, ou se o par sumir
app.handle("reports", "build", async (query, { signal }) =>
build(query, { signal }),
);Segurança
Toda mensagem que chega é respondida com base em três perguntas:
- Quem é o par? — no iframe,
allowedOriginsé obrigatório nolisten: o app nunca responde a um host arbitrário (o furo clássico dos wrappers de postMessage, onde o child responde a qualquer parent). Use"*"só em dev, e combine comframe-ancestors(CSP) no app, que barra o próprio embed. Num worker essa pergunta é outra: não há origem pra checar, e a confiança vem de quem instanciou o carrier — oconduito/portnão finge um gate que não existe. - O que está exposto? — só o que o app registra com
exposeexiste pro host. Escopo read-vs-mutate e consent por escopo são a próxima camada (roadmap). - Tem sessão? — fora do transporte: o app decide, por handler, o que exige credencial.
Onde ler o resto
Este README é o caminho feliz. A API completa e a filosofia de cada metade estão nos READMEs de
@conduito/core (protocolo, Endpoint, capabilities, cliente
tipado, como escrever um Transport) e
@conduito/frame (handshake, ciclo de vida do iframe,
allowlist de origem).
Precisa de só uma parte? Instale o pacote direto: @conduito/core é o protocolo puro (escreva o seu
Transport pra qualquer carrier), @conduito/frame é o transporte iframe e
@conduito/port o de worker.
