@conduito/core
v0.1.1
Published
The Conduito protocol — envelope, message validation, id correlation, and a bidirectional RPC + pub/sub Endpoint over an abstract transport. Zero-dependency, transport-agnostic.
Readme
@conduito/core
O protocolo do Conduito: o envelope, a validação de mensagem, a correlação por id, e um Endpoint — um
par RPC bidirecional + pub/sub — sobre um Transport abstrato. Zero-dependency e
transport-agnóstico: não conhece iframe, window, nem DOM.
Filosofia, em uma frase: separe o contrato do carrier. O que uma mensagem significa (o protocolo) não deve depender de por onde ela trafega (postMessage, worker, rede). O core é o contrato; um
Transport(ex.:@conduito/frame) é o carrier.
Por que assim
- Simétrico. Diferente dos wrappers de postMessage (parent chama child, child só emite), aqui os dois
lados criam um Endpoint e ambos podem
request/notify/emitehandle/on. Host e app são pares. - Provado + corrigido. Os algoritmos vêm do postmate (testado em produção): o envelope versionado, o
guard
sanitize, a correlação por id monotônico. Os gaps dele foram fechados: timeout, erro tipado, cancelamento, capabilities namespaçadas (ns), e RPC nos dois sentidos. - Sem DOM por construção. O core compila com
types: ["node"]e semlib DOM— então nem consegue tocarwindow/document. A fronteira core↔transporte é garantida pelo compilador.
Instalação
pnpm add @conduito/coreSozinho ele não fala com ninguém — pareie com um Transport (@conduito/frame pra iframe, @conduito/port
pra worker) ou escreva o seu.
API
O envelope
import { PROTO, OP, type Message } from "@conduito/core";
// PROTO = "conduito/1" — discrimina + versiona o wire
// OP = { HANDSHAKE, HELLO, REQUEST, REPLY, ERROR, NOTIFY, EVENT, DISCOVER, CANCEL }Toda mensagem é { proto: "conduito/1", op, … }. request/reply/error/cancel carregam id;
request/notify/event carregam ns (a capability) + method/event.
Validação
import { isMessage, sanitize } from "@conduito/core";
isMessage(x); // é um envelope conduito bem-formado?
sanitize(event, allowedOrigin); // origin-pin opcional + isMessage; refina event.data → MessageEndpoint
O coração. Dado um Transport, devolve o par RPC:
import { createEndpoint } from "@conduito/core";
const peer = createEndpoint(transport, { defaultTimeout: 30_000 });
// chamar o outro lado e aguardar (rejeita em erro do peer, timeout ou abort)
const sum = await peer.request<number>("math", "add", { a: 2, b: 3 });
const big = await peer.request("report", "build", q, {
signal: controller.signal,
});
// fire-and-forget (sem resposta)
peer.notify("log", "write", "hello");
// responder chamadas do outro lado — o handler recebe (payload, { signal })
const off = peer.handle(
"math",
"add",
({ a, b }: { a: number; b: number }) => a + b,
);
peer.handle("report", "build", async (q: Query, { signal }) =>
render(q, { signal }),
);
// pub/sub (nos dois sentidos)
const unsub = peer.on("tokens", "changed", (payload) => render(payload));
peer.emit("tokens", "changed", { id: 1 });
peer.reset(); // o par se foi: rejeita pendências (EDISCONNECTED), mantém handlers/listeners/capabilities
peer.dispose(); // encerra: para de ouvir + rejeita pendências (EDISPOSED)| método | direção | semântica |
| -------------------------------------- | -------- | --------------------------------------------------------- |
| request(ns, method, payload?, opts?) | → peer → | RPC com resposta (Promise; rejeita em erro/timeout/abort) |
| notify(ns, method, payload?) | → peer | fire-and-forget |
| handle(ns, method, handler) | ← peer | registra o handler (retorna unregister) |
| on(ns, event, listener) | ← peer | assina um evento (retorna unsubscribe) |
| emit(ns, event, payload?) | → peer | publica um evento |
| reset() | — | o par se foi: rejeita pendências, mantém o registro |
| dispose() | — | encerra + rejeita pendências |
opts de request/discover: { timeout?, signal? }. O timeout (default defaultTimeout, 0 desliga)
rejeita com ETIMEOUT; o signal (AbortSignal) rejeita com EABORTED. Nos dois casos o endpoint manda
um cancel pro peer, e o signal que o handler recebeu dispara — trabalho longo deve observá-lo. O
resultado de um handler cancelado não é enviado.
Erros são sempre um ConduitoError com .code estável (nada de string pra dar regex): ENOHANDLER,
EHANDLER, ETIMEOUT, EABORTED, EDISCONNECTED, EDISPOSED, ESEND (o send do request falhou —
original em .cause), EREMOTE, ERESERVED. Um handler pode lançar um ConduitoError com code próprio —
ele cruza o wire. Um Transport também: se o send lança um ConduitoError, o request rejeita com esse
code (é como o EQUEUEFULL dos transportes chega). isConduitoError(e) faz o narrow.
Nada é engolido em silêncio. Falhas sem canal de volta — um handler de notify que lança, um
transport.send que falha (peer sumiu), um cancel que não pôde ser enviado — vão pro hook opcional
onError:
createEndpoint(transport, {
onError: (error, { op, ns, method }) =>
log.warn("conduito", op, ns, method, error),
});Capabilities + discover
Uma capability é um domínio que o app expõe (tokens, routes, actions…): métodos chamáveis + eventos. O app
declara com expose; o peer descobre com discover — o "db pull".
import { defineCapability } from "@conduito/core";
// app: declara (registra no manifest E fia os handlers de uma vez)
export const tokensCap = defineCapability({
name: "tokens",
version: "1.0.0",
methods: {
get: (path: string) => store.get(path),
set: (t: Token) => store.set(t),
},
events: ["changed"],
meta: { title: "Design tokens" },
});
app.expose(tokensCap);
// host: descobre a superfície, depois chama
const { capabilities } = await host.discover();
// → [{ name: "tokens", version: "1.0.0", methods: ["get","set"], events: ["changed"], meta: {…} }]
const primary = await host.request("tokens", "get", "primary");expose é simétrico (host e app podem expor) e retorna um unexpose (remove do manifest + desfia os
handlers). O discover é auto-respondido pelo endpoint a partir do registro — você não escreve o handler
dele. O manifest é a base pra um host renderizar UI genérica de gestão, e pra uma IA saber o que pode chamar.
O manifest é vivo. Cada expose/unexpose emite o evento conduito:capabilities com o manifest
novo — um host conectado acompanha sem repetir o discover:
host.on(META_NS, "capabilities", ({ capabilities }) =>
renderSidebar(capabilities),
);O namespace conduito (META_NS) é reservado — expose com esse nome lança ERESERVED.
Cliente tipado
defineCapability guarda a forma literal da declaração (assinaturas dos métodos, nomes dos eventos). O host
pega só o tipo e ganha um cliente tipado — sem schema em runtime, sem string digitada à mão:
import { createClient } from "@conduito/core";
import type { tokensCap } from "./app-capabilities"; // type-only: os handlers nunca saem do app
const tokens = createClient<typeof tokensCap>(host, "tokens"); // o nome é checado contra a declaração
const primary = await tokens.request.get("primary"); // Promise<Token> — payload e retorno vêm do handler
tokens.notify.set({ name: "primary", value: "#000" }); // fire-and-forget, mesma assinatura
const off = tokens.on.changed((payload) => refresh(payload)); // só os eventos declarados existemrequest.x(payload?, opts?) aceita as mesmas RequestOptions (timeout, signal). Um handler declarado
sem parâmetro vira um método sem argumento; um (p) => … sem tipo vira unknown, como antes.
Transport (escrever o seu)
import type { Transport } from "@conduito/core";
const transport: Transport = {
send(message) {
/* entregar o envelope ao peer; pode lançar um ConduitoError (o code chega ao caller) */
},
subscribe(handler) {
/* entregar envelopes VALIDADOS (via sanitize) ao endpoint; retorna unsubscribe */
return () => {};
},
};O transporte é responsável por entrega + validação de origem — ele passa Messages já limpos ao
Endpoint e nunca vaza o carrier. Ver @conduito/frame pra o transporte iframe.
Internals: docs/ARCHITECTURE.md (no repositório).
