@afyadigital/receitapro-engine
v0.1.0
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 do
tokenopaco que recebe. Não restringe a origin do integrador — o produto éembed-from-any-origin(ver Segurança).
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, token,<br/>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,
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')!,
token: 'Bearer <jwt>',
initialValue: {
appointment: { id: 'atd-001' },
patient: { id: 'pac-001', name: 'Maria Silva', cpf: '05180005329' },
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), mas o campo é tipado: autocomplete e documentação de cada atributo aparecem direto no literal passado ao construtor, sem precisar anotar nada. Campos ainda não previstos no contrato continuam aceitos e passam intactos. O tipoInitialValuetambém pode ser importado do próprio@afyadigital/receitapro-engine— que re-exporta de@rx/contracts(dependência transitiva, host não instala separadamente) — quando o host quer declarar o objeto separado da chamada. 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 }], }
patient — o que o host pode pré-preencher
initialValue.patient cobre o cadastro que o host já tem. Só id e name são obrigatórios; todo o resto é opcional.
patient: {
id: 'pac-001', // id NO SEU sistema — chega ao produto como externalId
name: 'Maria Silva', // nome de exibição; vira nome social se diferir de civilName
cpf: '051.800.053-29', // com ou sem máscara
civilName: 'Maria Clara Gomes Silva',
motherName: 'Ana Silva',
email: '[email protected]',
phone: '+5511999998888', // ou 11999998888
birthdate: '1990-05-20', // ISO
sex: 'female',
weight: '64,5', // número em texto, vírgula ou ponto
height: '1,70',
address: { // tudo-ou-nada; omita o bloco se estiver incompleto
cep: '01001-000',
state: 'SP',
city: 'São Paulo',
street: 'Praça da Sé',
neighborhood: 'Sé',
number: '100',
complement: 'apto 12', // único campo opcional do bloco
},
lme: { /* ver PatientLme — tudo-ou-nada também */ },
}Pontos que mudam para quem vinha do widget legado:
addressé objeto, não string. O produto embarcado trabalha com endereço em campos separados; aceitar uma string exigiria parsing heurístico. Quebre a string nos campos dePatientAddress.agesaiu do contrato. Nunca existiu no legado e não era consumido. Enviebirthdate.patient.idé o id no seu sistema, não o id interno do produto. Ele chega comoexternalIde é a chave pela qual as edições feitas na tela voltam para você, pelo prescription-bff.
Payload inválido nunca derruba o iframe: nada hidrata, o host recebe
app:errorcomcode: 'invalid_initial_value'e a lista deissues, e a UI segue operável no fluxo normal de seleção de paciente.
appointment — atendimento e modo gerenciado
initialValue.appointment identifica o atendimento em que a prescrição acontece. Fica na raiz de initialValue, porque é contexto da prescrição e não atributo do paciente.
Enviado junto com patient, ele coloca a prescrição em modo gerenciado: a escolha do paciente passa a ser do host e a UI embarcada deixa de oferecer Trocar paciente e Selecionar. Isso impede a divergência entre o paciente do atendimento aberto no seu sistema e o paciente da prescrição emitida.
// Modo gerenciado: a seleção fica travada na UI.
initialValue: {
appointment: { id: 'atd-001' },
patient: { id: 'pac-001', name: 'Maria Silva' },
}
// Só pré-preenchimento: o médico continua podendo trocar de paciente.
initialValue: {
patient: { id: 'pac-001', name: 'Maria Silva' },
}Editar o cadastro do paciente continua disponível em modo gerenciado. O que o host gerencia é a escolha do paciente, não o cadastro. A edição é gravada no prescription-bff pela chave
external_iddo próprio host — hosts que precisem reagir a ela devem observar o prescription-bff, não oRxEngine.O legado usava a grafia
apointment. O contrato adotaappointment, com doisp.
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/>host envia update:token]
G --> I[iframe valida o token<br/>e emite app:ready { tokenValid }]
I --> J{tokenValid?}
J -->|true| K[prescrição pronta]
J -->|false| L[host reautentica]
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 K 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').
Após entregar o token via update:token, o iframe valida-o e emite app:ready
com { tokenValid: boolean }. Hosts podem observar esse evento para confirmar que a
prescrição está pronta e que o token foi aceito; quando tokenValid for false,
trate como sessão expirada (reautentique). O evento é aditivo — iframes que não o
emitem continuam funcionando, com o canal considerado pronto já no app:init.
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). |
| 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 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 |
| PatientAddress | initialValue.patient.address |
| PatientLme | initialValue.patient.lme |
| SkinColor | initialValue.patient.lme.skinColor |
| LmeResponsible | initialValue.patient.lme.filledBy.responsible |
| AppointmentInitial | initialValue.appointment |
| 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 { RxEngine } from '@afyadigital/receitapro-engine'
import { useEffect, useRef } from 'react'
export function PrescricaoEmbed({ token, patient }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const engine = new RxEngine({
container: containerRef.current,
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. Mensagens que trafegam por umMessagePortnão carregam origin (MessageEvent.origin === ''): o canal é ponto-a-ponto por construção, sem terceiros para interceptar ou injetar. - O único ponto sensível a origin é a transferência do
port2, protegida pelotargetOrigindopostMessage, que é a URL exata do iframe (não*) — o browser só entrega a mensagem, e o port junto, se awindowdo iframe estiver naquela origin. - Não há allowlist de origin do integrador (não existe um
allowedOriginna config). Ela seria redundante: otargetOriginacima já pinça o destino da transferência, e a origin do parent não participa de nenhuma garantia do canal. - 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 --noEmitExemplo de diagrama
O npm não renderiza diagramas
mermaidnativamente — por isso este é servido como imagem via mermaid.ink, que renderiza tanto no npm quanto no GitHub.
Publicação (Changesets)
Este é o único pacote do monorepo publicado no npm — como @afyadigital/receitapro-engine, no registro público. O versionamento é dirigido por Changesets a partir da raiz do monorepo (config em .changeset/config.json: access: public, baseBranch: main), e a publicação roda por um workflow manual do GitHub Actions — não pelo bot de release do Changesets — para casar com os workflows deploy-* existentes.
1. Durante o desenvolvimento — registre um changeset
Ao alterar o engine, descreva a mudança (patch / minor / major) e commite o .changeset/*.md gerado junto com o PR:
pnpm changesetOs changesets se acumulam na main até alguém cortar um release.
2. Para publicar — dispare o workflow de release
Um mantenedor dispara o workflow Release engine to npm (GitHub → Actions → Run workflow, definido em .github/workflows/release-engine-npm.yml). O job:
- Resolve a versão. Se há changesets pendentes, aplica-os com
changeset version(atualiza oversionnopackage.jsondo engine — não edite à mão — e escreve oCHANGELOG.md). Se não há changeset pendente, o inputbumpdo workflow (patchpor padrão) gera um para o engine, permitindo cortar o release mesmo assim. Havendo changeset commitado, o input é ignorado (evita bump duplo). - Commita o bump como
chore: release packages. - Builda só o engine —
turbo run build --filter=@afyadigital/receitapro-engine. - Publica com
pnpm changeset publish— sobe os pacotes não-privados (hoje, só o engine) e cria a tag@afyadigital/receitapro-engine@<versão>. - Faz push do commit de versão e das tags de volta para a
main.
Pré-requisitos (uma vez)
- Secret
NPM_TOKEN— token de automação com permissão de publish no escopo@afyadigital. A auth do npm é escrita no.npmrcsó no runner (nunca commitada). - Permissão de escrita para o workflow (
contents: write) — commit de versão + tags. - Se a
mainfor protegida, libere o push dogithub-actions[bot]ou troque oGITHUB_TOKENpor um PAT que faça bypass do ruleset.
@rx/contractsnão é publicado. Ele éprivatee seus tipos são inlinados nodist/index.d.tsdeste pacote viavite-plugin-dts(rollupTypes+bundledPackages) — por isso é umadevDependency(build-time) e o pacote publicado não tem runtime deps. Apenas pacotes não-privatepublicam; manter os internos privados evita publish acidental.
