@afyadigital/receitapro-engine
v0.9.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— que já leva otokene oinitialValue—, 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 (update:token,update:patient) e umEventEmitterpara escutar eventos de ciclo de vida (app:init,app:ready,token:expired,prescription:created,app:error). O que cada command faz hoje do lado do produto está em Catálogo de commands.
A credencial vai na montagem. O
tokendo construtor é transportado peloengine:mount, então o caminho feliz não exige command nenhum do host:render()e o iframe já sobe autenticado.commands.update('token', …)cobre os dois casos posteriores — renovação em sessão e correção, quando a validação recusa o token da montagem.
O que o engine não faz
- Não valida o conteúdo de 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. A única regra que o engine conhece é estrutural, olha presença e não conteúdo, e existe porque falharia silenciosamente do outro lado:initialValue.appointmentexigeinitialValue.patient(verappointment). - 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',<br/>{ token, initialValue }, [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.update('patient', 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' } / 'app:ready' /<br/>'token:expired' / 'prescription:created' / 'app:error'
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 type { AppReadyPayload } from '@afyadigital/receitapro-engine'
import { RxEngine } from '@afyadigital/receitapro-engine'
const container = document.getElementById('rx-container')!
const engine = new RxEngine({
container,
token: physicianToken, // access_token CRU, sem o prefixo `Bearer` — ver abaixo
})
// Registre os listeners ANTES de `render()`. Os eventos chegam em callbacks do
// `load` do iframe: quem registra depois pode perder o evento e ficar esperando
// para sempre por algo que já aconteceu.
//
// Nenhum listener é obrigatório para a prescrição abrir — o token e o
// `initialValue` foram no handshake. `app:init` só interessa a quem vai enviar
// commands: é o marco a partir do qual o canal os aceita.
engine.events.listen<AppReadyPayload>('app:ready', (e) => {
if (e.payload?.tokenValid === false) {
// O token entregue na montagem foi recusado pelo BFF. Como o host não tem como
// validá-lo antes de entregar, uma tentativa de correção é legítima: reemita e
// mande por `commands.update('token', novoToken)`. Recusado de novo, é credencial
// errada — corrija a emissão em vez de insistir.
// Para o token que foi aceito e venceu, o sinal é `token:expired`.
}
})
engine.render()Duas regras que respondem pela maior parte do suporte
- Registre os listeners antes de
render().render()insere o iframe e oapp:initpode chegar antes da próxima linha do seu código. Umlisten(…)registrado depois nunca dispara — o sintoma é "a prescrição abre e o host nunca fica sabendo de nada", inclusive de umapp:readycomtokenValid: falseou de umtoken:expiredque ele precisaria atender. - Envie o
access_tokencru, semBearer. O produto embarcado monta o headerAuthorization: Bearer <token>sozinho. Passar`Bearer ${access_token}`produzAuthorization: Bearer Bearer eyJ…e todas as chamadas voltam401— a tela abre e falha logo em seguida. Vale para os dois lugares em que o token aparece:RxEngineConfig.tokenecommands.update('token', …).
O
tokendo construtor é obrigatório e é transportado peloengine:mount: é a entrega, não uma cópia decorativa. Até a 0.5.0 o campo era validado na construção e descartado, e a credencial só chegava se o host disparasseupdate:tokennoapp:init— quem preenchia a config e esquecia o command montava um iframe sem credencial, sem receber sinal nenhum de que a montagem nunca ia concluir.
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: physicianToken, // cru, sem `Bearer`
initialValue: {
appointment: { id: 'atd-001' },
patient: { id: 'pac-001', name: 'Maria Silva', cpf: '05180005329' },
},
})
engine.render()Hoje o
initialValuehidratapatienteappointment. As chaves irmãsmedications,vaccineseexamssão campos reservados — em breve: já existem no contrato de tipos e são aceitas sem erro, mas ainda não são consumidas, e a prescrição abre sem os itens. Enviá-las é inofensivo, só não produz efeito. Do lado dos commands o recorte é ainda mais estrito: qualquer escopo fora deupdate:token/update:patienté recusado comapp:error(unsupported_command).
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 = { appointment: { id: 'atd-001' }, patient: { id: 'pac-001', name: 'Maria Silva' }, }
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: 'F', // 'M' masculino | 'F' feminino
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 "O bloco lme" abaixo — tudo-ou-nada também */ },
}Campos de patient
| Campo | Tipo | Obrigatório | Observações |
| ------------ | ---------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | string | ✓ | Id no seu sistema, até 36 caracteres; chega ao produto como externalId |
| name | string | ✓ | Nome de exibição; vira nome social quando difere de civilName |
| cpf | string | — | Com ou sem máscara. Validado: CPF inválido invalida o payload inteiro |
| civilName | string | — | Nome de registro, quando difere do de exibição |
| motherName | string | — | — |
| email | string | — | Validado como e-mail |
| phone | string | — | Com ou sem máscara/DDI. Número incompleto invalida o payload |
| birthdate | string | — | Validado: ISO YYYY-MM-DD, dia real, não-futura; '20/05/1990' invalida o payload. Sufixo de hora ('…T00:00:00Z') é aceito e descartado |
| sex | PatientSex | — | Validado: dicionário fechado 'M' (masculino) ou 'F' (feminino); outro valor invalida o payload. 'female'/'male' seguem aceitos, depreciados |
| weight | string | — | Número em texto, kg, vírgula ou ponto ('64,5') |
| height | string | — | Número em texto, metros ('1,70') |
| address | PatientAddress | — | Bloco tudo-ou-nada |
| lme | PatientLme | — | Bloco tudo-ou-nada |
O bloco address
Tudo-ou-nada: enviar address implica enviar todos os campos obrigatórios. Sem o endereço completo, omita o bloco inteiro.
| Campo | Tipo | Obrigatório | Observações |
| -------------- | -------- | ----------- | ----------------------------- |
| cep | string | ✓ | 8 dígitos, com ou sem máscara |
| state | string | ✓ | Sigla de 2 letras ('SP') |
| city | string | ✓ | — |
| street | string | ✓ | Logradouro, sem número |
| neighborhood | string | ✓ | — |
| number | string | ✓ | Texto — aceita 's/n' |
| complement | string | — | Único campo opcional do bloco |
O bloco lme
Dados exclusivos do Laudo de Medicamento Especializado. Opcional como conjunto, mas também tudo-ou-nada — e com duas regras condicionais validadas no recebimento.
| Campo | Tipo | Obrigatório | Observações |
| ----------------- | -------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------- |
| document | { type: 'cpf' \| 'cns'; value: string } | ✓ | value com ou sem máscara |
| skinColor | 'branca' \| 'preta' \| 'parda' \| 'amarela' \| 'indigena' | ✓ | Raça/cor autodeclarada |
| isCapable | boolean | ✓ | Capacidade civil do paciente |
| filledBy | { responsible: LmeResponsible; name?: string; cpf?: string } | ✓ | responsible: 'patient' \| 'patientMother' \| 'responsible' \| 'physician' \| 'other' |
| ethnicity | string | se skinColor: 'indigena' | Etnia indígena declarada |
| responsibleName | string | se isCapable: false | Nome do responsável legal |
lme: {
document: { type: 'cpf', value: '05180005329' },
skinColor: 'indigena',
ethnicity: 'Guarani', // obrigatório porque skinColor é 'indigena'
isCapable: false,
responsibleName: 'Ana Silva', // obrigatório porque isCapable é false
filledBy: { responsible: 'responsible', name: 'Ana Silva', cpf: '05180005329' },
}As condicionais são opcionais no tipo e obrigatórias na validação: o TypeScript não acusa, mas o payload é reprovado no recebimento (
invalid_initial_valuena montagem,invalid_patientno command) e nada é hidratado.
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.
O mesmo formato vale depois da montagem: sem appointment, o host troca ou remove o paciente em runtime com commands.update('patient', …), sem remontar o iframe.
Payload inválido nunca derruba o iframe: nada hidrata, o host recebe
app:errorcomtype: 'invalid_initial_value'e a lista deissues, e a UI segue operável no fluxo normal de seleção de paciente.
appointment — atendimento e contexto
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. Serve para amarrar a receita ao atendimento aberto no seu sistema — é rastreabilidade, e não muda nada na UI.
Quem coloca a prescrição em modo gerenciado é o patient: mandar o paciente pela integração é dizer que a escolha é sua, e a UI embarcada deixa de oferecer Trocar paciente e Selecionar. Isso impede a divergência entre o paciente aberto no seu sistema e o paciente da prescrição emitida.
// Modo gerenciado: basta o `patient` para a seleção ficar travada na UI.
initialValue: {
patient: { id: 'pac-001', name: 'Maria Silva' },
}
// Com atendimento: mesma trava, mais o contexto do atendimento na receita.
// `patient` é obrigatório sempre que `appointment` for enviado.
initialValue: {
appointment: { id: 'atd-001' },
patient: { id: 'pac-001', name: 'Maria Silva' },
}O que o host envia × o que acontece na UI
| O que o host envia | O que acontece na UI |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| patient (com ou sem appointment) | Modo gerenciado: o paciente fica travado, Trocar paciente e Selecionar somem da tela, Editar continua disponível |
| appointment sem patient | Recusado: não compila em TypeScript, Error no construtor e — se o config for mutado depois — render() vira no-op com app:error (appointment_without_patient). Nenhum iframe é criado |
| patient inválido | Nada hidrata, o modo gerenciado não ativa, a UI segue operável na seleção normal e o host recebe app:error (invalid_initial_value) |
| initialValue ausente ou null | Prescrição em branco, no fluxo normal de seleção de paciente. Não é erro e não emite nada |
O modo gerenciado não é uma flag: ele é derivado da origem do paciente em tela. É por isso que um paciente reprovado na validação não trava nada — sem paciente hidratado não há o que travar. E a trava é da UI do médico, não do canal: você continua trocando ou limpando o paciente por commands.update('patient', …) (ver commands.update).
Enviar
appointmentsempatienté erro. Um atendimento sem paciente é um contexto pela metade — não há a quem amarrar a receita. Como não há estado válido a montar, a prescrição não chega a carregar:
- em TypeScript, não compila — o par está no tipo
InitialValue, então o editor acusa na hora, apontando opatientque falta;new RxEngine({ … })lançaError— a checagem de runtime segue valendo para hosts em JavaScript ou que contornem o tipo com umas; envolva a construção emtry/catch, como você já faz paracontaineretoken;- se o
initialValuefor invalidado depois da construção, orender()vira no-op (nenhum iframe é inserido) e o host recebeapp:errorcomtype: 'appointment_without_patient'.Os dois caminhos de runtime também gritam um
console.errornomeando o campo que falta, em qualquer ambiente. É de propósito: oErrorcostuma acabar engolido pelotry/catchde quem só quer mostrar uma tela de erro, e oapp:errordepende de haver listener — sem o log, o sintoma que sobra é "a prescrição não abre". O log nunca inclui oinitialValue, que carrega dados do paciente.O paciente muda em runtime, com ou sem atendimento:
commands.update('patient', …)troca ou limpa (payload: null) sem remontar o iframe. O atendimento em si não muda por command — ele só entra pela montagem.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/>renovação e troca de paciente]
E --> I[iframe valida o token do mount<br/>e emite app:ready { tokenValid }]
I --> J{tokenValid?}
J -->|true| K[prescrição pronta]
J -->|false| L[host reemite e entrega<br/>por update:token]
L --> I
K --> M[médico conclui →<br/>prescription:created { pubId }]
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 M fill:#dcfce7,stroke:#22c55e
style H fill:#fee2e2,stroke:#ef4444O engine:mount leva o token e o initialValue, então o caminho feliz não passa por command nenhum: o iframe hidrata, valida a credencial e emite app:ready. O 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').
Depois de validar o token — o da montagem, e o de cada update:token com valor novo —
o iframe 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, o iframe fica numa tela de espera por credencial (sem fazer
nenhuma chamada ao BFF) e a recuperação é entregar outro token por update:token. O
evento é aditivo — iframes que não o emitem continuam funcionando, com o canal
considerado pronto já no app:init.
Sem token utilizável nada é requisitado. Se a montagem chegar sem o campo token
(ou com um valor que não é token), o host recebe app:error com missing_token /
invalid_token e o iframe não faz uma única chamada ao BFF. Os dois são diagnóstico,
não porta fechada: o canal segue aberto e um update:token bom faz a tela seguir sem
remontar.
prescription:created — prescrição criada
Quando o médico conclui e o backend cria a prescrição, o iframe emite
prescription:created com o pubId — o ID da prescrição pai, a chave pela qual
o seu sistema consulta a prescrição na API.
import type { PrescriptionCreatedPayload } from '@afyadigital/receitapro-engine'
engine.events.listen<PrescriptionCreatedPayload>('prescription:created', ({ payload }) => {
void consultarPrescricao(payload.pubId)
})O evento sai assim que a criação responde — antes e independentemente da assinatura
digital. Ele diz que a prescrição existe e já é consultável, não que foi assinada: o
médico pode seguir sem assinar, e a assinatura pode falhar depois sem que isso mude o
que você já recebeu. É emitido uma única vez por prescrição e não ecoa id — é
evento de ciclo de vida, não resposta a command.
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:
engine.commands.update<T>(scope, payload) // Promise<T> — "update:<scope>"
engine.commands.add<T>(scope, payload) // Promise<T> — "add:<scope>"
engine.commands.search<T>(scope, payload?) // Promise<T> — "search:<scope>"Os três métodos existem porque o engine é transporte puro — ele não conhece scopes. Mas o canal só trata dois:
update:tokeneupdate:patient(ver Catálogo de commands). Qualquer outroscopetrafega e volta recusado comapp:error(type: 'unsupported_command').
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 (PatientInitial, InitialValue, AppErrorPayload, AppReadyPayload) — re-exportados de @rx/contracts como dependência transitiva.
Uso com await
import type { PatientInitial } from '@afyadigital/receitapro-engine'
import { CommandError } from '@afyadigital/receitapro-engine'
const paciente: PatientInitial = { id: 'pac-002', name: 'João Souza' }
try {
const { status } = await engine.commands.update<{
status: 'updated' | 'cleared'
message: string
}>('patient', paciente)
console.log('paciente aplicado:', status) // 'updated'
} catch (err) {
if (err instanceof CommandError) {
// err.payload é o AppErrorPayload: { type, message, issues? }
console.error('falhou:', err.payload)
}
}Paralelismo natural
await Promise.all([
engine.commands.update('token', tokenRenovado),
engine.commands.update('patient', paciente),
])Eventos de ciclo de vida (
app:init,app:ready,token:expired,prescription:created) não correspondem a um command e continuam sendo entregues viaengine.events.listen(...). A Promise API substitui apenas a correlaçãocommand ↔ resposta.
Trocar o paciente depois da montagem — update:patient
initialValue.patient cobre a montagem. Para hosts que trocam o paciente sem recarregar o iframe — o médico muda de ficha na sua tela e a prescrição precisa acompanhar — existe commands.update('patient', ...).
import type { PatientInitial } from '@afyadigital/receitapro-engine'
import { CommandError } from '@afyadigital/receitapro-engine'
const { status, message } = await engine.commands.update<{
status: 'updated' | 'cleared'
message: string
}>('patient', {
id: 'pac-002', // id NO SEU sistema — mesma chave do `initialValue.patient`
name: 'João Souza',
cpf: '05180005329',
})
console.log(status, message) // → 'updated' 'Paciente alterado com sucesso'O payload é o mesmo PatientInitial aceito em initialValue.patient — mesmos campos obrigatórios (id, name), mesmas regras de tudo-ou-nada em address e lme. Veja patient — o que o host pode pré-preencher para a lista completa.
É substituição, não merge. O paciente enviado troca integralmente o anterior; campos omitidos não são herdados da montagem nem da troca anterior. Mande o cadastro completo a cada chamada.
Limpar o paciente
payload: null remove o paciente e devolve a seleção à tela embarcada:
await engine.commands.update('patient', null) // → { status: 'cleared', ... }O que a Promise resolve
update:patient é o único command que resolve com conteúdo — os demais resolvem com null:
| Campo | Valor |
| --------- | ---------------------------------------------------------------------------- |
| status | 'updated' (paciente aplicado) ou 'cleared' (removido) |
| message | Texto pronto para log/toast. Não ramifique por ele — ramifique por status. |
O que a troca não mexe
- Os itens já rascunhados (medicamentos, vacinas, exames) permanecem. É deliberado: só o host sabe se a troca é correção de cadastro ou mudança de atendimento. Quando for mudança de verdade, cabe ao host limpar a prescrição.
- O
appointment— que, se existir, bloqueia o command por completo (abaixo).
Recusas
| payload.type | Quando | Estado após a recusa |
| ----------------- | ------------------------------------------------------------- | ---------------------------------------------- |
| invalid_patient | Payload reprovado no schema do produto (ver payload.issues) | O paciente anterior é preservado integralmente |
try {
await engine.commands.update('patient', novoPaciente)
} catch (err) {
if (err instanceof CommandError) {
const { type, issues } = err.payload as { type: string; issues?: unknown[] }
if (type === 'invalid_patient') console.error('cadastro rejeitado:', issues)
}
}O modo gerenciado não bloqueia este command. A trava vale para a UI do médico; o host, dono da escolha do paciente, troca ou corrige quando quiser — inclusive com um atendimento na montagem. Limpar com
payload: nulldevolve a seleção ao médico. Note que editar o cadastro pela UI também continua liberado: o que o host gerencia é a escolha do paciente, não o cadastro.
Sessão — formato do token e renovação
O token de prescrição é a credencial de todas as chamadas que o produto embarcado faz ao BFF. O host o obtém no seu backend (ver guia de integração) e o entrega pelo canal.
Formato: o JWT cru. O produto embarcado monta o header Authorization: Bearer <token> por conta própria — mandar o valor já prefixado produziria Bearer Bearer eyJ… e 401 em toda chamada.
await engine.commands.update('token', access_token) // ✅ o JWT cru
await engine.commands.update('token', `Bearer ${access_token}`) // ⚠️ prefixo tolerado, não useVersões do guia até a 0.3.1 pediam o valor prefixado. O prefixo continua sendo aceito — e removido — para não quebrar quem integrou seguindo aquela versão, mas o contrato é o JWT cru.
O token viaja no handshake. new RxEngine({ token }) é a entrega: o valor vai no engine:mount e o iframe monta autenticado, sem depender de nenhum command. commands.update('token', …) tem dois papéis, os dois posteriores à montagem:
- renovação em sessão — o token vence e o host entrega o próximo (abaixo);
- correção — a montagem trouxe um token que o BFF recusou (
app:readycomtokenValid: false) e o host entrega outro. O host não tem como saber se o token presta antes de entregar: só a validação contra o BFF diz. Uma tentativa de correção é caso normal de operação; recusada de novo, é credencial errada.
Até a 0.5.0 este command era a entrega primária. Hosts que continuarem disparando-o no app:init seguem funcionando — é o mesmo valor entregue duas vezes, e o segundo não reemite app:ready.
Sem refresh token: renovar é reemitir — repetir a chamada que gerou o token. Use o expires_in da resposta do BFF para renovar antes do vencimento; é o caminho preferido, porque nenhuma chamada do médico precisa falhar primeiro. O expires_in é configuração de servidor: leia o valor da resposta em vez de fixar um número no código.
// Caminho preferido: agende pela validade informada pelo BFF, com margem.
const { access_token, expires_in } = await emitirTokenDePrescricao()
setTimeout(renovar, (expires_in - 60) * 1000)
// Rede de segurança: o produto avisa quando a credencial for recusada.
engine.events.listen('token:expired', renovar)Reentregar por update:token retoma a sessão no lugar onde ela parou — a prescrição em rascunho é preservada e o iframe não é remontado.
Catálogo de commands
O canal tem exatamente dois commands: sessão e contexto do paciente. Nenhum dos dois é necessário no caminho feliz — a credencial e o contexto inicial vão na montagem.
| Método | type no envelope | Payload | Resolve com |
| --------------------------------- | ------------------ | ------------------------------------- | --------------------- |
| commands.update('token', ...) | update:token | string — o JWT cru, sem prefixo | null |
| commands.update('patient', ...) | update:patient | PatientInitial ou null | { status, message } |
update:tokennão é a entrega inicial da credencial. O token da montagem vai noRxEngineConfige viaja noengine:mount; o command cobre a renovação em sessão e a correção do token que a validação recusou. Ver Sessão — formato do token e renovação.
Qualquer outro
scopeé recusado. O iframe respondeapp:errorcomtype: 'unsupported_command'e aPromiserejeita comCommandError. Compor a prescrição a partir do sistema do parceiro (adicionar medicamento, vacina, exame, buscar no catálogo) não passa pelo canal: o que o host tem para isso é oinitialValuena montagem.
prescription:createdavisa, mas só enquanto o iframe está aberto. O canal emite o evento assim que o backend cria a prescrição (verprescription:created— prescrição criada), mas se o médico fechar a aba antes disso o host não recebe nada — não há reentrega. Para saber da receita de forma durável, independente da aba estar aberta, o lugar continua sendo um webhook do backend. Esse webhook ainda não existe: se o seu fluxo depende de uma notificação durável, fale com o time de Prescrição antes de planejar a integração. O evento do canal não substitui o webhook.
Envelope obrigatório. O produto embarcado só processa mensagens com
typeeid. O engine sempre gera oid, então isso só afeta quem tenta falar com o canal por fora da API pública — mensagens semidsão descartadas em silêncio e nenhuma resposta volta.
Eventos de retorno
A coluna id? indica se o evento ecoa o id interno do envelope — quando sim, resolve ou rejeita a Promise correspondente; quando não, é entregue apenas via engine.events.listen(...).
| Evento | Quando | id? | payload |
| ---------------------- | ------------------------------------------------------------------------------------ | ------------------------- | ----------------------------------------------------- |
| app:init | Handshake concluído: initialValue processado, canal pronto para commands | não | — |
| app:ready | Quando a validação do token conclui — o da montagem, e o de cada update:token novo | não | { tokenValid: boolean } |
| token:expired | Um token que já fora aceito passou a ser recusado pelo BFF (401/403) | não | — |
| prescription:created | O backend criou uma prescrição — antes da assinatura digital | não | { pubId: string } |
| app:error | token ou initialValue recusado na montagem, ou command recusado | montagem não; command sim | AppErrorPayload |
| update:token | Ack do command homônimo | sim | null |
| update:patient | Sucesso do command homônimo | sim | { status: 'updated' \| 'cleared', message: string } |
Os eventos de resposta ecoam o próprio type do command (update:token → update:token), e não uma forma <escopo>:<verbo>. O único evento de domínio emitido é prescription:created; nomes como medication:added, token:updated e prescription:saved não são emitidos — não escreva listeners contando com eles.
app:readyé reemitido a cada token novo. O primeiro sai da validação do token da montagem; depois, cadaupdate:tokencom um valor diferente do anterior faz o produto revalidar e emitir de novo. O mesmo token entregue duas vezes não reemite.
tokenValid: falseetoken:expirednão se sobrepõem. Uma recusa já na primeira validação éapp:readycomtokenValid: false— o token nunca foi aceito. Um token que foi aceito e depois deixou de valer chega comotoken:expired. Nos dois casos a ação imediata é a mesma — gerar um token novo e chamarcommands.update('token', novoToken), sem remontar o iframe —, mas o que elas dizem sobre a emissão é oposto:token:expiredé rotina, etokenValid: falserepetido é a sua emissão que está errada. Renovar de forma proativa peloexpires_indo BFF continua sendo o caminho preferido.
A tela de espera é diferente nos dois casos. Um token que expirou põe um aviso de reconexão por cima da prescrição, que continua montada: rascunho, formulário em edição e diálogos abertos sobrevivem. Um token que nunca foi aceito não tem prescrição atrás para preservar, e a tela diz que está aguardando a liberação do acesso. Em nenhum dos dois há login dentro do iframe — a saída é sempre o host reentregar o token.
Erros e casos de borda
| Situação | Como o host fica sabendo | Estado resultante |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| initialValue ausente, null ou não-objeto | Nenhum sinal — não é erro | Prescrição em branco, seleção de paciente normal |
| initialValue reprovado na validação | app:error com type: 'invalid_initial_value' e issues | Nada hidrata; o app:init sai do mesmo jeito e a UI segue operável |
| Montagem sem o campo token | app:error com type: 'missing_token' | Nenhuma chamada ao BFF; tela de espera por credencial. Recupera com update:token |
| Montagem com token que não é token utilizável | app:error com type: 'invalid_token' | Nada é gravado; idem acima — recupera com update:token |
| appointment sem patient | Erro de compilação em TS; Error no construtor; render() no-op + app:error (appointment_without_patient); console.error em qualquer ambiente | Nenhum iframe é criado |
| Command antes de render() ou depois de unmount() | CommandError com payload.type: 'no_active_channel' (+ console.warn fora de produção) | Nada trafega pelo canal |
| update:patient com payload inválido | CommandError com type: 'invalid_patient' e issues | O paciente anterior é preservado integralmente |
| Payload não clonável (structuredClone) | CommandError com type: 'post_failed' e a cause original | Nada foi postado |
| app:ready com tokenValid: false | Evento de ciclo de vida | O token nunca foi aceito. Tela de espera por credencial; uma tentativa de correção por update:token é legítima, recusa repetida é erro de emissão |
| app:init nunca chega | Silêncio — o engine não tem timeout próprio | Implemente um timeout no host (referência: 20s) e ofereça retry |
| app:ready nunca chega | Silêncio | Mesmo timeout. O evento é aditivo: um iframe em versão antiga pode não emiti-lo — considere o canal pronto no app:init |
| unmount() com commands em voo | Cada Promise pendente rejeita com CommandError (type: 'channel_closed') | Canal fechado, iframe removido, listeners limpos |
Os
typede transporte (no_active_channel,post_failed,channel_closed) não cruzam oMessageChannel— são gerados pelo próprio engine e só aparecem emCommandError.payload. Os de contrato (invalid_initial_value,appointment_without_patient,missing_token,invalid_token,invalid_patient,unsupported_command— mais o obsoletopatient_change_not_allowed) fazem parte deAppErrorType, exportado pelo pacote: importe o tipo em vez de comparar strings soltas.
Migração do widget legado
Hosts que vinham do widget anterior precisam dos ajustes abaixo — os três primeiros são de contrato e quebram a integração se ficarem de fora:
| Antes (widget legado) | Agora (@afyadigital/receitapro-engine) |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| apointment (um p) | appointment, com dois p, na raiz do initialValue — e exige patient junto |
| address como string única | address como objeto (PatientAddress), bloco tudo-ou-nada. Não há parsing de string do lado da prescrição: quebre a string nos campos antes de enviar |
| age | Removido do contrato. Envie birthdate em ISO (YYYY-MM-DD) |
| Trocar de paciente remontando o iframe | commands.update('patient', …) — a qualquer momento, com ou sem appointment |
| Token com prefixo Bearer | access_token cru, tanto no RxEngineConfig.token quanto no commands.update('token', …) |
Além disso, um comportamento novo que muda a operação: enviar patient trava a seleção na UI (modo gerenciado), com ou sem appointment. Hosts que mandavam paciente só para pré-preencher e contavam com o médico podendo trocar devem omitir o patient e deixar a seleção com o médico.
A edição do cadastro feita dentro da prescrição não volta pelo canal. Não há evento de "paciente editado". A alteração é gravada no prescription-bff pela chave
external_id— o mesmopatient.idque você enviou. Hosts que precisem refletir a edição no próprio cadastro devem observar o prescription-bff, não oRxEngine.
API
class RxEngine
constructor(config: RxEngineConfig)
| Campo | Tipo | Obrigatório | Descrição |
| --------------- | -------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| container | HTMLElement | ✓ | Elemento DOM onde o iframe será inserido (appendChild). |
| token | string | ✓ | Credencial da sessão — o JWT cru, sem Bearer. Vai no engine:mount. |
| initialValue? | InitialValue | — | 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. |
| devqaUrl? | string | — | URL http/https do web a embutir quando o ambiente resolvido é development. Ignorada em staging/production e quando não é URL absoluta — o motivo vai para o console. |
Lança Error se container ou token estiverem ausentes, ou se initialValue trouxer appointment sem patient.
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.
Também vira no-op quando o initialValue traz appointment sem patient (caso em que o config foi mutado depois da construção): nenhum iframe é inserido e o host recebe app:error com type: 'appointment_without_patient'. Não lança — render() costuma ser chamado fora de try/catch, então o sinal é o evento.
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').
commands.update('patient', …) troca o paciente depois da montagem (payload PatientInitial) ou o remove (payload: null), e é o único command que resolve com conteúdo: { status: 'updated' | 'cleared', message: string }. Ver update:patient.
Vale a qualquer momento depois da montagem, com ou sem appointment: o modo gerenciado trava a UI do médico, não o canal do host.
commands.update('token', …) entrega a credencial da sessão — o JWT cru. Ver Sessão — formato do token e renovação.
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 — ver AppErrorPayload abaixo — ou um marcador local do engine. |
AppErrorPayload
Payload de todo app:error, tanto o evento de ciclo de vida quanto a resposta a um command que vira CommandError:
| Campo | Tipo | Conteúdo |
| --------- | ----------------------------------- | ------------------------------------------------------------------------------------ |
| type | AppErrorType | Motivo do erro — é por aqui que o host ramifica. |
| message | string | Descrição técnica para log/diagnóstico. Não é copy de UI e pode mudar sem aviso. |
| issues | readonly unknown[] | undefined | Detalhes por campo de uma falha de validação. Opaco por design: serve para log. |
AppErrorType é a fonte única de verdade dos motivos — importe o tipo em vez de comparar strings soltas:
| type | Quando | Efeito |
| ----------------------------- | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| invalid_initial_value | O envelope de initialValue não passou na validação da montagem | Nada hidrata — não há recuperação sem remontar |
| appointment_without_patient | initialValue.appointment sem patient | render() vira no-op, nenhum iframe é criado |
| invalid_patient | Payload de commands.update('patient', …) inválido | O paciente anterior é preservado |
| patient_change_not_allowed | Obsoleto — nunca mais emitido (ver nota abaixo) | — |
| missing_token | A montagem chegou sem o campo token | Nenhuma chamada ao BFF; recupera por update:token |
| invalid_token | O token da montagem, ou o payload de commands.update('token', …), não é um JWT utilizável | O token anterior é preservado; na montagem, nada é gravado |
| unsupported_command | scope fora do contrato do canal (ver Catálogo de commands) | Nada muda |
Os dois primeiros invalidam a montagem: um app:init pode chegar depois deles (a UI embarcada segue operável do lado dela), então o host que precisa do contexto travado deve tratá-los como terminais e não voltar ao estado "pronto".
missing_token e invalid_token também acontecem na montagem, mas não são terminais: o host não tem como validar o token antes de entregá-lo, então o conserto previsto é entregar outro por commands.update('token', …) — sem remontar. Enquanto isso o iframe não faz uma única chamada ao BFF.
patient_change_not_allowed não é mais emitido. Até a 0.7.0 ele recusava update:patient enquanto houvesse atendimento na montagem; hoje o command vale sempre. O literal segue em AppErrorType para não quebrar o switch exaustivo de quem já o trata — e hosts presos a um engine anterior continuam recebendo a recusa local daquela versão.
Falhas de transporte não usam AppErrorType — nunca cruzam o MessageChannel e só aparecem em CommandError.payload: no_active_channel, post_failed e channel_closed.
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 |
| PatientSex | initialValue.patient.sex |
| SkinColor | initialValue.patient.lme.skinColor |
| LmeResponsible | initialValue.patient.lme.filledBy.responsible |
| AppointmentInitial | initialValue.appointment |
| AppErrorPayload | payload de app:error e de CommandError |
| AppErrorType | Motivos de app:error — ramifique por ele |
| AppReadyPayload | payload de app:ready ({ tokenValid }) |
| HostEvent | Nomes de eventos de ciclo de vida web→host (token:expired) |
| MedicationPayload | initialValue.medications[] — reservado, ainda sem efeito |
| VaccinePayload | initialValue.vaccines[] — reservado, ainda sem efeito |
| ExamPayload | initialValue.exams[] — reservado, ainda sem efeito |
import {
type AppReadyPayload,
type InitialValue,
type PatientInitial,
} 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, devqaUrl?: string): string
Resolve a URL do iframe a partir de um override explícito ou de process.env.RX_ENV.
import { IFRAME_URLS, resolveIframeUrl } from '@afyadigital/receitapro-engine'
resolveIframeUrl('staging') // → URL do ambiente de staging
resolveIframeUrl() // → resolve por `process.env.RX_ENV`, default `production`
resolveIframeUrl('development', 'https://web-da-stack.example') // → a URL passada
resolveIframeUrl('production', 'https://web-da-stack.example') // → produção; devqaUrl é ignorada
resolveIframeUrl('development', 'web-da-stack.example') // → host local; devqaUrl sem esquema é recusada
IFRAME_URLS.development // → host local, para desenvolvimento do próprio produtoA
devqaUrlvira osrcdo iframe e otargetOrigindopostMessage, então só é aceita como URLhttp/httpsabsoluta. Todo descarte — ambiente errado ou valor inválido — explica o motivo emconsole.error.
Exemplo completo — host React
import type { AppReadyPayload } from '@afyadigital/receitapro-engine'
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 },
})
// `tokenValid: false` é o token que nunca foi aceito. Uma tentativa de correção
// cobre o caso legítimo (o token venceu entre a emissão e a montagem); da segunda
// em diante é erro de emissão, e insistir vira laço de emissão e recusa.
let correcaoTentada = false
// Listeners antes do `render()` — depois dele o `app:init` já pode ter passado.
const offReady = engine.events.listen<AppReadyPayload>('app:ready', async (e) => {
if (e.payload?.tokenValid !== false) return
if (correcaoTentada) return // revise a emissão em vez de reemitir
correcaoTentada = true
const novoToken = await emitirTokenDePrescricao() // seu backend
await engine.commands.update('token', novoToken)
})
// Rede de segurança da renovação: o token foi aceito e deixou de valer.
// Reemita pelo seu backend e reentregue — sem remontar o iframe.
const offExpired = engine.events.listen('token:expired', async () => {
const novoToken = await emitirTokenDePrescricao() // seu backend
await engine.commands.update('token', novoToken)
})
engine.render()
return () => {
offReady()
offExpired()
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. - O
tokenviaja nessa mesma transferência, e por isso está sob a mesma proteção: oengine:mounté postado com otargetOriginexato do iframe, então o browser não entrega a mensagem — nem a credencial — se awindowde destino não estiver naquela origin. Ele não trafega porwindow.postMessagecom'*'em nenhum momento, e depois do handshake nunca mais sai dawindow. - 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' }) | Força staging. |
| process.env.RX_ENV='development' (build do host) | Resolve para development (host local). |
| new RxEngine({ ..., env: 'development', devqaUrl }) | Resolve para a devqaUrl no lugar do host local. |
| 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
