@afyadigital/receitapro-engine
v0.0.1
Published
RxEngine — host-side integration for embedding @receitapro/web via iframe + MessageChannel
Keywords
Readme
@afyadigital/receitapro-engine
SDK do lado do host para embarcar o produto de prescrição @receitapro/web num iframe — pense em Stripe Elements ou no widget do Intercom: o integrador adiciona uma <div> ao layout, instancia o engine e o restante é cuidado pela própria Afya.
O engine cuida de três coisas, e somente estas:
- Renderiza o iframe apontando para a URL pública do produto (resolvida por ambiente —
production/staging/development). A URL nunca é exposta ao integrador. - Estabelece um canal privado via
MessageChannel: faz o handshakeengine:mount, transfere oport2ao iframe e passa a relaiar tudo peloport1. Após esse passo, nenhuma mensagem do produto trafega porwindow.postMessage— o canal é invisível para scripts da página do host. - Expõe uma API
Promise-based para enviar commands (add:medication,update:token,search:vaccine, ...) e umEventEmitterpara escutar eventos de ciclo de vida (app:init,token:expired, ...).
O que o engine não faz
- Não valida payloads. É transporte puro: não importa Zod, não conhece DTOs, não sabe o que é "medication" ou "vaccine". Toda validação de domínio vive no
@receitapro/web. - Não enfileira commands antes do
render()ou apósunmount()— falhas explícitas em vez de bugs silenciosos. - Não conhece o host além da
allowedOrigindeclarada e dotokenopaco que recebe.
Modelo conceitual
sequenceDiagram
autonumber
participant Host as Host (sua aplicação)
participant Engine as RxEngine
participant Iframe as iframe (rx-web)
Host->>Engine: new RxEngine({ container, allowedOrigin,<br/>token, initialValue? })
Host->>Engine: render()
Engine->>Iframe: iframe.src = IFRAME_URL<br/>(carrega rx-web)
Engine->>Iframe: window.postMessage('engine:mount', [port2])
Note over Engine,Iframe: Único window.postMessage do contrato —<br/>após isto, tudo pelo MessageChannel privado<br/>(port1 ↔ port2, cross-origin, isolado da window)
Host->>Engine: await commands.add('medication', payload)
Engine->>Iframe: port1.postMessage({ type, id, payload })
Iframe-->>Engine: port2.postMessage({ event, id, payload })
Engine-->>Host: Promise<Result> resolve(payload)
Iframe-->>Engine: { event: 'app:init' } / 'token:expired' / ...
Engine-->>Host: engine.events.listen(...) — ciclo de vida
Host->>Engine: unmount()
Engine->>Iframe: port1.close() + iframe.remove()O id que carrega cada envelope no fio ({ type, id, payload }) é detalhe interno de transporte — o engine o usa para casar request/response e nunca o expõe na API pública. Para o host, commands.add(...) é uma async function qualquer.
Instalação
Dentro do monorepo:
pnpm --filter <consumer> add @afyadigital/receitapro-engineEm hosts externos (após publicação):
npm install @afyadigital/receitapro-engineUso básico
import { RxEngine } from '@afyadigital/receitapro-engine'
const container = document.getElementById('rx-container')!
const engine = new RxEngine({
container,
allowedOrigin: 'https://app.iclinic.com.br',
token: 'Bearer <jwt>',
})
engine.events.listen('app:init', () => {
// o iframe está pronto — canal aberto, stores hidratadas
})
engine.render()Uso com initialValue (pré-hidratação)
initialValue é entregue no payload da mensagem interna engine:mount, junto com a transferência do port2. Ele é processado pelo iframe antes do primeiro render do React, eliminando o flash de tela vazia e a necessidade de despachar uma sequência de commands após o app:init.
import { RxEngine } from '@afyadigital/receitapro-engine'
const engine = new RxEngine({
container: document.getElementById('rx-container')!,
allowedOrigin: 'https://app.iclinic.com.br',
token: 'Bearer <jwt>',
initialValue: {
patient: { id: 'pac-001', name: 'Maria Silva', age: 42 },
medications: [{ id: 'med-001', name: 'Amoxicilina 500mg', dosage: '1 cp 8/8h', quantity: 21 }],
exams: [{ id: 'exam-001', name: 'Hemograma completo' }],
},
})
engine.render()O engine não inspeciona
initialValueem runtime (transporte puro). Para DX, o host importa o tipoInitialValuedo próprio@afyadigital/receitapro-engine— que re-exporta de@rx/contracts(dependência transitiva, host não instala separadamente). Tipos são apagados no build; o bundle runtime do engine permanece sem referência a@rx/contracts.import type { InitialValue } from '@afyadigital/receitapro-engine' import { RxEngine } from '@afyadigital/receitapro-engine' const initialValue: InitialValue = { patient: { id: 'pac-001', name: 'Maria Silva' }, medications: [{ id: 'med-001', name: 'Amox', dosage: '8/8h', quantity: 21 }], }
Ciclo de vida
flowchart TD
A[new RxEngine(config)] --> B[render()]
B --> C[iframe inserido no DOM]
C -->|load event| D[engine:mount + port2<br/>host → iframe via window.postMessage]
D --> E[iframe hidrata stores<br/>e emite app:init]
E --> F[engine.events.dispatch<br/>→ host listener]
F --> G[canal pronto —<br/>commands podem ser enviados]
G -.->|qualquer momento| H[unmount()<br/>port.close + iframe.remove<br/>+ rejeita Promises pendentes]
style A fill:#dbeafe,stroke:#3b82f6
style D fill:#fef3c7,stroke:#f59e0b
style G fill:#dcfce7,stroke:#22c55e
style H fill:#fee2e2,stroke:#ef4444O host deve sempre aguardar app:init antes de enviar commands. Commands enviados antes de render() (ou após unmount()) rejeitam imediatamente com CommandError (type: 'no_active_channel') e warn em dev — o engine não enfileira para evitar mascarar bugs de ordem. No unmount(), Promises ainda pendentes rejeitam com CommandError (type: 'channel_closed').
Commands — API Promise
Após o handshake, toda comunicação com o iframe acontece pelo MessageChannel privado via engine.commands. Cada método retorna uma Promise — await direto, Promise.all([...]) para paralelismo:
engine.commands.add<T>(scope, payload) // Promise<T> — "add:<scope>"
engine.commands.update<T>(scope, payload) // Promise<T> — "update:<scope>"
engine.commands.search<T>(scope, payload?) // Promise<T> — "search:<scope>"Cada chamada:
- Gera um
idinterno (UUID v4) — detalhe de transporte, não exposto. - Embrulha o envelope
{ type, id, payload }e posta noport1. - Registra uma Promise pendente que resolve com o
payloaddo evento de resposta (cujoidcasa) ou rejeita comCommandErrorse o iframe emitirapp:error.
O engine não valida o payload — toda validação acontece no @receitapro/web (Zod). Para DX, hosts importam os tipos do próprio @afyadigital/receitapro-engine (MedicationPayload, VaccinePayload, ExamPayload, InitialValue, PatientInitial) — re-exportados de @rx/contracts como dependência transitiva.
Uso com await
import type { MedicationPayload } from '@afyadigital/receitapro-engine'
import { CommandError } from '@afyadigital/receitapro-engine'
try {
const medication = await engine.commands.add<MedicationPayload>('medication', {
id: 'med-001',
name: 'Amoxicilina 500mg',
dosage: '1 cp 8/8h',
quantity: 21,
})
console.log('medicamento adicionado:', medication)
} catch (err) {
if (err instanceof CommandError) {
// err.payload contém { type: 'invalid_payload' | 'internal_error' | ..., ... }
console.error('falhou:', err.payload)
}
}Paralelismo natural
const [medication, vaccine] = await Promise.all([
engine.commands.add('medication', medPayload),
engine.commands.add('vaccine', vacPayload),
])Eventos de ciclo de vida (
app:init,token:expired, etc.) não correspondem a um command e continuam sendo entregues viaengine.events.listen(...). A Promise API substitui apenas a correlaçãocommand ↔ resposta.
Catálogo de commands
| Método | type no envelope | Payload típico |
| ------------------------------------ | ------------------- | --------------------------------- |
| commands.add('medication', ...) | add:medication | MedicationDto |
| commands.add('vaccine', ...) | add:vaccine | VaccineDto |
| commands.add('exam', ...) | add:exam | ExamDto |
| commands.update('token', ...) | update:token | string (Bearer ...) |
| commands.update('config', ...) | update:config | { fullscreen?: boolean } |
| commands.update('medication', ...) | update:medication | Partial<MedicationDto> & { id } |
| commands.search('medication', ...) | search:medication | { query?, form? } |
| commands.search('vaccine', ...) | search:vaccine | { ageGroup? } |
| commands.search('exam', ...) | search:exam | { category? } |
A lista de
scopes válidos é definida pelocommandSchemano@receitapro/web. Umscopedesconhecido ainda assim trafega pelo canal — o iframe responde comapp:error(invalid_payload) e aPromiserejeita comCommandError.
Eventos de retorno
A coluna id? indica se o evento ecoa o id interno do envelope — quando sim, resolve/rejeita a Promise correspondente; quando não, é entregue apenas via engine.events.listen(...).
| Evento | Quando | id? (transporte) |
| --------------------- | ------------------------------------- | --------------------- |
| app:init | Canal pronto após handshake | não |
| app:error | ZodError ou erro runtime | sim, quando extraível |
| medication:added | Sucesso de add:medication | sim |
| medication:updated | Sucesso de update:medication | sim |
| medication:searched | Sucesso de search:medication | sim |
| vaccine:added | Sucesso de add:vaccine | sim |
| exam:added | Sucesso de add:exam | sim |
| token:updated | Sucesso de update:token | sim |
| token:expired | Detecção de 401/403 nas APIs internas | não |
| prescription:saved | Save concluído | sim |
Sobre a coluna
Quando: descreve o gatilho que faz orx-webemitir o evento — em geral, sucesso de um command equivalente. Eventos semid(ex:app:init,token:expired) não estão amarrados a uma chamada decommands.*; o host os consome viaengine.events.listen(...).
API
class RxEngine
constructor(config: RxEngineConfig)
| Campo | Tipo | Obrigatório | Descrição |
| --------------- | -------------------------------------------- | ----------- | ----------------------------------------------------------------------------- |
| container | HTMLElement | ✓ | Elemento DOM onde o iframe será inserido (appendChild). |
| allowedOrigin | string | ✓ | Origin pública do host integrador. Ex: https://app.iclinic.com.br. |
| token | string | ✓ | JWT (ou token equivalente) repassado ao iframe. |
| initialValue? | unknown | — | Estado inicial opcional para hidratar as stores antes do primeiro render. |
| env? | 'production' \| 'staging' \| 'development' | — | Override explícito de ambiente. Default: process.env.RX_ENV → production. |
Lança Error se container, allowedOrigin ou token estiverem ausentes.
render(): void
Cria o iframe, instala o MessageChannel e registra o listener de load que disparará o handshake engine:mount. Idempotente — chamadas subsequentes são no-op.
unmount(): void
Remove o iframe do DOM, fecha o port1, remove o listener de load e limpa todos os event handlers. Idempotente e seguro para chamar antes de render().
events: EngineEventEmitter
Dispatcher de eventos recebidos pelo port1. Use engine.events.listen(eventName, handler) (ver abaixo).
commands: Commands
API de envio de commands ao iframe. Cada método retorna uma Promise<T> que resolve com o payload do evento de resposta ou rejeita com CommandError:
| Método | Retorno |
| ------------------------------------- | ------------ |
| commands.add<T>(scope, payload) | Promise<T> |
| commands.update<T>(scope, payload) | Promise<T> |
| commands.search<T>(scope, payload?) | Promise<T> |
Antes de render() (ou após unmount()) chamadas rejeitam imediatamente com CommandError (type: 'no_active_channel') e warn em dev. No unmount(), Promises pendentes rejeitam com CommandError (type: 'channel_closed').
CommandError extends Error
Lançado pela rejeição das Promises de commands.*. Carrega:
| Campo | Tipo | Conteúdo |
| --------- | --------- | ---------------------------------------------------------------------------------------------------- |
| message | string | Resumo textual — extraído de payload.message/payload.type ou um default genérico. |
| payload | unknown | Payload original do app:error ({ type, errors?, message?, ... }) ou marcadores locais do engine. |
Tipos típicos de payload.type: invalid_payload, internal_error, invalid_initial_value (vindos do receptor); no_active_channel, channel_closed (locais do engine).
listen(event, handler): Unsubscribe
Atalho equivalente a engine.events.listen(event, handler) — útil para encadear diretamente na instância do engine.
class EngineEventEmitter
| Método | Descrição |
| -------------------------------------- | ----------------------------------------------------------------------------------- |
| listen(event, handler) → Unsubscribe | Registra um handler. Retorna função de unsubscribe. |
| dispatch(message) | Despacha um payload recebido pelo port1. Ignora payloads sem { event: string }. |
| clear() | Remove todos os handlers (chamado pelo RxEngine.unmount()). |
Isolamento de erros: se um handler lançar, o erro é logado via console.error e os demais handlers continuam executando.
Tipos públicos (re-exportados de @rx/contracts)
| Tipo | Uso |
| ------------------- | ---------------------------------------------------------------- |
| InitialValue | Hidratação inicial (new RxEngine({ initialValue })) |
| PatientInitial | initialValue.patient |
| MedicationPayload | commands.add('medication', ...) / initialValue.medications[] |
| VaccinePayload | commands.add('vaccine', ...) / initialValue.vaccines[] |
| ExamPayload | commands.add('exam', ...) / initialValue.exams[] |
import { type InitialValue, type MedicationPayload } from '@afyadigital/receitapro-engine'Re-exports são apenas tipos — apagados no build, sem custo de runtime. O host não precisa instalar
@rx/contractsdiretamente.
resolveIframeUrl(env?: RxEnv): string
Resolve a URL do iframe a partir de um override explícito ou de process.env.RX_ENV. Cai para production quando o valor é inválido.
import { IFRAME_URLS, resolveIframeUrl } from '@afyadigital/receitapro-engine'
resolveIframeUrl('staging') // → 'https://prescricao.staging.afya.com.br'
IFRAME_URLS.development // → 'http://localhost:3000'Exemplo completo — host React
import { useEffect, useRef } from 'react'
import { RxEngine } from '@afyadigital/receitapro-engine'
export function PrescricaoEmbed({ token, patient }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const engine = new RxEngine({
container: containerRef.current,
allowedOrigin: window.location.origin,
token,
initialValue: { patient },
})
const off = engine.events.listen('app:init', async () => {
// canal pronto — commands podem ser disparados como async functions
try {
await engine.commands.update('config', { fullscreen: true })
} catch (err) {
// CommandError quando o iframe responde com app:error
}
})
engine.render()
return () => {
off()
engine.unmount()
}
}, [token, patient])
return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />
}Segurança
- O engine usa um
MessageChannelprivado — após o handshake, toda a comunicação acontece pelo parport1/port2, fora do alcance de outraswindows no host. - O
targetOrigindopostMessageé a URL exata do iframe (não*). - A defesa contra clickjacking vive dentro do iframe (
@receitapro/web), pois o produto éembed-from-any-origin(modelo SaaS widget).
Resolução de ambiente
| Estratégia | Resultado |
| -------------------------------------------------- | ----------------------------------------------------- |
| new RxEngine({ ..., env: 'staging' }) | Force staging. |
| process.env.RX_ENV='development' (build do host) | Resolve para development (http://localhost:3000). |
| Nada definido / valor inválido | Resolve para production. |
Variáveis de ambiente são lidas em build time pelos bundlers usuais (Vite, Webpack, Next.js).
Scripts
pnpm --filter @afyadigital/receitapro-engine build # vite (esm + .d.ts via vite-plugin-dts)
pnpm --filter @afyadigital/receitapro-engine dev # vite build --watch
pnpm --filter @afyadigital/receitapro-engine test # vitest run
pnpm --filter @afyadigital/receitapro-engine test:watch # vitest
pnpm --filter @afyadigital/receitapro-engine lint # eslint
pnpm --filter @afyadigital/receitapro-engine type-check # tsc --noEmitPublicação (Changesets)
Este pacote é publicado no npm como @afyadigital/receitapro-engine via Changesets, a partir da raiz do monorepo.
Ao alterar o engine, gere um changeset descrevendo a mudança (
patch/minor/major) e commite o.changeset/*.mdgerado junto com o PR:pnpm changesetPara publicar, um mantenedor dispara o workflow Release engine to npm (GitHub Actions →
workflow_dispatch). Ele aplica as versões pendentes (changeset version), commita o bump, builda o engine e rodachangeset publish, criando a tag@afyadigital/receitapro-engine@<versão>.
Requer o secret
NPM_TOKEN(token de automação com publish no escopo@afyadigital) e permissão de escrita para o workflow. Apenas pacotes não-privados publicam — hoje, somente o engine.
