@ploomescrm/bridge
v1.0.0
Published
Bridge de comunicação tipado e **agnóstico de transporte** entre uma aplicação host e os apps que ela embute — um `<iframe>` no web, uma `<WebView>` no mobile.
Keywords
Readme
@ploomescrm/bridge
Bridge de comunicação tipado e agnóstico de transporte entre uma aplicação
host e os apps que ela embute — um <iframe> no web, uma <WebView> no mobile.
A mesma API roda no web e no mobile; só o transporte muda.
┌──────────────────────┐ ┌──────────────────────────┐
│ Host │ READY ◀────── │ App (iframe / webview) │
│ web → iframe │ ──────▶ INIT │ │
│ mobile → WebView │ ◀─ navigate ─▶ │ createApp(...) │
│ createHost(...) │ ◀── event ───▶ │ │
│ │ ◀─ request ──▶ │ │
└──────────────────────┘ └──────────────────────────┘O bridge carrega zero regra de negócio: ele só conhece o handshake, o
roteamento, um canal genérico de eventos e um canal genérico de
pergunta-resposta (RPC). Qualquer significado de domínio (inclusive o catálogo
de rotas) vive nos apps e trafega como event / request.
Fluxos de dados
Config (Host → App) — enviado no payload INIT (HostConfig):
userKey, userId, apiUrls, whitelabel, e os opcionais itemId (number) e
featureFlags (Record<string, boolean>). É uma base mínima — estenda-a
passando um tipo mais amplo como TConfig para createHost/createApp.
apiUrls exige apenas apiUrl; adicione outros endpoints sob qualquer chave
string ([endpoint: string]: string | undefined) ou modele-os no seu TConfig.
Roteamento (bidirecional) — o route adapter é uma função e não pode ser
serializado, então cada lado tem o seu (onNavigate) e o outro lado o dispara
com navigate(...):
app.navigate(to)→host.onNavigate(app pede ao host para rotear).host.navigate(to)→app.onNavigate(host pede ao app para rotear).
to é uma string parecida com uma URL, porém sem ids (ex.: /deals/detail).
Ids e query params vão em params — assim web e mobile resolvem a mesma rota com
adapters diferentes. O bridge não conhece nenhuma rota: cada consumidor
define sua própria união de strings e a passa no generic TRoute de
createHost / createApp. O catálogo vive no código dos consumidores ou chega
em runtime (por exemplo, num campo do seu TConfig estendido).
Convenção — rota desconhecida: o
onNavigatedo receptor deve tratar umtonão reconhecido com um fallback explícito (logar e ignorar, ou abrir uma tela de erro) — o outro lado pode estar numa versão mais nova e emitir rotas que este lado ainda não conhece. Quando o emissor precisa saber antes se a rota é aceita, use o RPC (request) para perguntar.
Eventos genéricos (bidirecional) — fire-and-forget:
app.emit(name, data)→host.onEvent({ name, data }).host.emit(name, data)→app.onEvent({ name, data }).
Tipe-os passando um EventMap ({ [nome]: payload }) como TEvents; o
onEvent recebe uma união discriminada — switch (event.name) estreita data.
Todos os payloads são restritos a JsonValue: funções, Date e refs de DOM
falham no typecheck (declare o map com type, não interface).
Requisições (RPC, bidirecional) — quando um lado precisa de resposta:
app.request(name, params)→host.onRequest(req)→ promise tipada no app.host.request(name, params)→app.onRequest(req)→ promise tipada no host.
Tipe-as com um RequestMap ({ [nome]: { params, result } }) no generic
TRequests. A promise rejeita em timeout (padrão 10 s, configurável por
chamada), quando o handler do outro lado lança, quando não há handler
registrado, ou no dispose() — ela nunca fica pendurada. Exceções no handler
chegam ao chamador só com a message do erro — não coloque dados sensíveis
em mensagens de erro, elas cruzam o bridge.
Instalação
pnpm add @ploomescrm/bridgeZero dependências de runtime. Distribui ESM + CJS + types.
Web — host
import {
createHost,
createIframeHostTransport,
type HostConfig,
} from '@ploomescrm/bridge';
// O vocabulário é SEU — declare com `type` (não `interface`) e passe nos
// generics; sem eles, payloads e resultados ficam tipados como JsonValue.
type Route = '/deals/detail' | '/reports' | '/settings';
type Events = {
'theme-changed': { dark: boolean };
'item-updated': { id: number };
};
type Requests = {
'can-navigate': { params: { to: string }; result: { allowed: boolean } };
'get-state': { params: null; result: { dirty: boolean } };
};
const iframe = document.querySelector<HTMLIFrameElement>('#app-frame')!;
const host = createHost<HostConfig, Route, Events, Requests>({
transport: createIframeHostTransport({
iframe, // ou um getter: () => iframeRef.current
appOrigin: APP_ORIGIN, // '*' apenas quando a origem do app não pode ser fixada
}),
config: () => ({
userKey,
userId,
apiUrls: { apiUrl, reportsApiUrl },
whitelabel: { enabled: true, name: 'Acme', brandColor: '#123456' },
itemId: 42,
featureFlags: { newEditor: true },
}),
// Chamado a cada READY do app (handshake inicial, reloads). É o ponto certo
// para reagir ao app ficar disponível; `appId` diferencia múltiplos apps.
onReady: ({ appId }) => console.log('app pronto:', appId),
// O route adapter — decide entre roteamento nativo e externo (web).
// Trate rotas desconhecidas com fallback explícito.
onNavigate: ({ to, params, replace }) => {
if (isKnownRoute(to))
history[replace ? 'replace' : 'push'](withParams(to, params));
else console.warn('rota desconhecida vinda do app:', to);
},
// Eventos genéricos vindos do app — o bridge não sabe o que significam.
onEvent: (event) => {
switch (event.name) {
case 'item-updated':
refreshItem(event.data.id);
break;
}
},
// Perguntas vindas do app — retorno (ou exceção) volta como resposta tipada.
onRequest: (req) => {
switch (req.name) {
case 'can-navigate':
return { allowed: isKnownRoute(req.data.to) };
default:
throw new Error(`requisição desconhecida: ${req.name}`);
}
},
});
// host → app: rotear o app, empurrar um evento, perguntar algo.
// Chamadas feitas antes do handshake (READY → INIT) são enfileiradas e
// entregues, em ordem, logo após ele — nunca são descartadas em silêncio.
host.navigate({ to: '/deals/detail', params: { id: 42 } });
host.emit('theme-changed', { dark: true });
const state = await host.request('get-state', null); // rejeita em 10s se não houver resposta
// depois, ex.: no unmount:
host.dispose();Web — app embutido (conteúdo do iframe)
import {
createApp,
createIframeAppTransport,
type HostConfig,
} from '@ploomescrm/bridge';
// Route / Events / Requests: as mesmas type aliases do exemplo do host.
const app = createApp<HostConfig, Route, Events, Requests>({
transport: createIframeAppTransport({ hostOrigin: HOST_ORIGIN }),
// Roteamento host → app: o host pediu para o app navegar internamente.
onNavigate: ({ to, params }) => router.go(to, params),
// Eventos host → app.
onEvent: (event) => applyHostEvent(event),
// Perguntas host → app.
onRequest: (req) => answerHostRequest(req),
});
// Envia READY e resolve com o HostConfig. Rejeita (nunca pendura) em timeout,
// erro do host ou dispose — trate a falha.
const ctx = await app.connect(); // ou app.connect({ timeoutMs: 5000 })
// ctx.userKey, ctx.apiUrls, ctx.whitelabel, ctx.itemId, ctx.featureFlags
// app → host: pedir roteamento (ids/params fora da string `to`)
app.navigate({ to: '/deals/detail', params: { id: 42 } });
// app → host: emitir um evento genérico
app.emit('item-updated', { id: 42 });
// app → host: perguntar e aguardar resposta tipada
const { allowed } = await app.request('can-navigate', { to: '/reports' });Mensagens host:navigate / host:event / host:request que chegarem antes
do config inicial ficam bufferizadas e são entregues logo após o primeiro
onConfig — seus handlers podem assumir que app.context existe.
Mobile — host (React Native)
O React Native não tem DOM, então o transporte host da WebView é bombeado
manualmente: encaminhe as mensagens de saída para a ref da WebView e alimente
de volta os dados do onMessage dela.
import { useRef, useMemo } from 'react';
import { WebView } from 'react-native-webview';
import { createHost, createWebViewHostTransport } from '@ploomescrm/bridge';
function EmbeddedApp() {
const webViewRef = useRef<WebView>(null);
const transport = useMemo(
() =>
createWebViewHostTransport({
postToWebView: (s) => webViewRef.current?.postMessage(s),
}),
[],
);
useMemo(() => {
createHost({
transport,
config: { userKey, userId, apiUrls, whitelabel, itemId: 7 },
onNavigate: ({ to, params }) =>
navigation.navigate(...resolveRoute(to, params)), // roteamento nativo
onEvent: (event) => handleAppEvent(event),
});
}, [transport]);
return (
<WebView
ref={webViewRef}
source={{ uri: APP_URL }}
onMessage={(e) => transport.receiveFromWebView(e.nativeEvent.data)}
/>
);
}Mobile — app embutido (página dentro da WebView)
O código do app é idêntico ao do app web, exceto pelo transporte:
import { createApp, createWebViewAppTransport } from '@ploomescrm/bridge';
const app = createApp({ transport: createWebViewAppTransport() });
const ctx = await app.connect();
app.navigate({ to: '/quotes/detail', params: { id: 7 } });Dica: detecte o ambiente uma vez e escolha o transporte — tudo depois disso é compartilhado.
const transport = 'ReactNativeWebView' in window ? createWebViewAppTransport() : createIframeAppTransport({ hostOrigin: HOST_ORIGIN });
Como funciona
- O app faz
subscribeno seu transporte na criação (captura antecipada), e entãoconnect()enviaREADY. Escutar antes de aguardar significa que umINITque o host dispara no instante em que o frame carrega fica bufferizado, nunca é perdido. - Enquanto o
INITnão chega, o app reenvia oREADYcom backoff exponencial (250 ms dobrando até o teto de 2 s) — isso cobre o host que só assina o transporte depois doconnect()do app. O reenvio para ao receberINIT, ao estourar o timeout, ou nodispose(). - O host responde a cada
READYcomINITcarregando oHostConfig(re-resolvendo a factory, se for uma). Se a factory lançar, o host chama seuonErrorlocal e enviahost:error— oconnect()pendente do app rejeita com esse erro. connect()resolve com essa configuração — ou rejeita no timeout (padrão 10 s,connect({ timeoutMs })), citando as causas prováveis: host ausente, origem errada (appOrigin/hostOrigin), iframe sandboxed. Ele nunca fica pendurado para sempre.onConfigdispara no config inicial e a cada atualização posterior empurrada pelo host (chamehost.sendConfig()após um refresh da user key, etc.). Quando a factory é async e envios se sobrepõem, só a resolução mais recente é postada — configs nunca chegam fora de ordem.
Toda mensagem é envelopada em um Envelope carimbado com um
discriminador e a versão do protocolo, de modo que mensagens de outras
bibliotecas que compartilham o canal postMessage são ignoradas. No web, a
checagem de origem adiciona uma segunda camada; passe '*' apenas quando a
origem for genuinamente desconhecível. No app dentro de WebView, o transporte
só aceita por padrão eventos com a cara da injeção nativa do
react-native-webview (sem source, origem vazia) — scripts de terceiros na
página não conseguem forjar um host:init via postMessage comum.
Política de compatibilidade de versão
Cada envelope carrega a PROTOCOL_VERSION do remetente, e ela é verificada
no recebimento pelos dois lados:
- Esperado: host e app rodam a mesma versão do protocolo (a mesma major do pacote nos dois lados).
- Divergência: gera um aviso — o hook
onVersionMismatch({ local, remote, type })se você registrá-lo, ou umconsole.warnúnico por sessão. A mensagem ainda é processada: o bridge é forward-compatible por padrão, então uma divergência não derruba a comunicação sozinha. - Incompatibilidade real (mudança de shape de envelope/payload): exige bump
coordenado — atualize o pacote nos dois lados na mesma janela. É para isso
que a
PROTOCOL_VERSIONexiste; useonVersionMismatchpara telemetria e para decidir degradar funcionalidades quando as versões divergirem.
API
| Export | Propósito |
| ---------------------------------------------------------- | ----------------------------------------------------------- |
| createHost(opts) | Lado do host (sendConfig, navigate, emit, request). |
| createApp(opts) | Lado do app (connect, navigate, emit, request). |
| createIframeHostTransport / createIframeAppTransport | Web (postMessage). |
| createWebViewHostTransport / createWebViewAppTransport | Mobile (RN WebView). |
| createPostMessageTransport | Bloco de baixo nível para transportes web customizados. |
| Transport | Implemente para qualquer outro ambiente. |
| JsonValue, EventMap, RequestMap | Restrições de serialização e tipagem de eventos/RPC. |
Veja src/types.ts e src/protocol.ts para a superfície tipada completa.
Desenvolvimento
pnpm install
pnpm typecheck # tsc --noEmit
pnpm test # vitest
pnpm build # tsdown → dist (esm + cjs + d.ts)
pnpm format # prettier --write
pnpm change # cria um changeset antes de um bump de versãoLicença
Veja LICENSE.md.
