npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@afyadigital/receitapro-engine

v0.9.0

Published

RxEngine — host-side integration for embedding @receitapro/web via iframe + MessageChannel

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:

  1. Renderiza o iframe apontando para a URL pública do produto (resolvida por ambiente — production / staging / development). A URL nunca é exposta ao integrador.
  2. Estabelece um canal privado via MessageChannel: faz o handshake engine:mount — que já leva o token e o initialValue —, transfere o port2 ao iframe e passa a relaiar tudo pelo port1. Após esse passo, nenhuma mensagem do produto trafega por window.postMessage — o canal é invisível para scripts da página do host.
  3. Expõe uma API Promise-based para enviar commands (update:token, update:patient) e um EventEmitter para 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 token do construtor é transportado pelo engine: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.appointment exige initialValue.patient (ver appointment).
  • Não enfileira commands antes do render() ou após unmount() — falhas explícitas em vez de bugs silenciosos.
  • Não conhece o host além do token opaco 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-engine

Em hosts externos (após publicação):

npm install @afyadigital/receitapro-engine

Uso 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

  1. Registre os listeners antes de render(). render() insere o iframe e o app:init pode chegar antes da próxima linha do seu código. Um listen(…) registrado depois nunca dispara — o sintoma é "a prescrição abre e o host nunca fica sabendo de nada", inclusive de um app:ready com tokenValid: false ou de um token:expired que ele precisaria atender.
  2. Envie o access_token cru, sem Bearer. O produto embarcado monta o header Authorization: Bearer <token> sozinho. Passar `Bearer ${access_token}` produz Authorization: Bearer Bearer eyJ… e todas as chamadas voltam 401 — a tela abre e falha logo em seguida. Vale para os dois lugares em que o token aparece: RxEngineConfig.token e commands.update('token', …).

O token do construtor é obrigatório e é transportado pelo engine: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 disparasse update:token no app: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 initialValue hidrata patient e appointment. As chaves irmãs medications, vaccines e exams sã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 de update:token / update:patient é recusado com app:error (unsupported_command).

O engine não inspeciona initialValue em 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 tipo InitialValue també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_value na montagem, invalid_patient no 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 de PatientAddress.
  • age saiu do contrato. Nunca existiu no legado e não era consumido. Envie birthdate.
  • patient.id é o id no seu sistema, não o id interno do produto. Ele chega como externalId e é 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:error com type: 'invalid_initial_value' e a lista de issues, 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 appointment sem patient é 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 o patient que falta;
  • new RxEngine({ … }) lança Error — a checagem de runtime segue valendo para hosts em JavaScript ou que contornem o tipo com um as; envolva a construção em try/catch, como você já faz para container e token;
  • se o initialValue for invalidado depois da construção, o render() vira no-op (nenhum iframe é inserido) e o host recebe app:error com type: 'appointment_without_patient'.

Os dois caminhos de runtime também gritam um console.error nomeando o campo que falta, em qualquer ambiente. É de propósito: o Error costuma acabar engolido pelo try/catch de quem só quer mostrar uma tela de erro, e o app:error depende de haver listener — sem o log, o sintoma que sobra é "a prescrição não abre". O log nunca inclui o initialValue, 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_id do próprio host — hosts que precisem reagir a ela devem observar o prescription-bff, não o RxEngine.

O legado usava a grafia apointment. O contrato adota appointment, com dois p.


Ciclo de vida

flowchart TD
    A[new RxEngine&#40;config&#41;] --> B[render&#40;&#41;]
    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&#40;&#41;<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:#ef4444

O 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:token e update:patient (ver Catálogo de commands). Qualquer outro scope trafega e volta recusado com app:error (type: 'unsupported_command').

Cada chamada:

  1. Gera um id interno (UUID v4) — detalhe de transporte, não exposto.
  2. Embrulha o envelope { type, id, payload } e posta no port1.
  3. Registra uma Promise pendente que resolve com o payload do evento de resposta (cujo id casa) ou rejeita com CommandError se o iframe emitir app: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 via engine.events.listen(...). A Promise API substitui apenas a correlação command ↔ 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: null devolve 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 use

Versõ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:ready com tokenValid: 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:token não é a entrega inicial da credencial. O token da montagem vai no RxEngineConfig e viaja no engine: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 responde app:error com type: 'unsupported_command' e a Promise rejeita com CommandError. 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 é o initialValue na montagem.

prescription:created avisa, mas só enquanto o iframe está aberto. O canal emite o evento assim que o backend cria a prescrição (ver prescription: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 type e id. O engine sempre gera o id, então isso só afeta quem tenta falar com o canal por fora da API pública — mensagens sem id sã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, cada update:token com um valor diferente do anterior faz o produto revalidar e emitir de novo. O mesmo token entregue duas vezes não reemite.

tokenValid: false e token:expired não se sobrepõem. Uma recusa já na primeira validação é app:ready com tokenValid: false — o token nunca foi aceito. Um token que foi aceito e depois deixou de valer chega como token:expired. Nos dois casos a ação imediata é a mesma — gerar um token novo e chamar commands.update('token', novoToken), sem remontar o iframe —, mas o que elas dizem sobre a emissão é oposto: token:expired é rotina, e tokenValid: false repetido é a sua emissão que está errada. Renovar de forma proativa pelo expires_in do 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 type de transporte (no_active_channel, post_failed, channel_closed) não cruzam o MessageChannel — são gerados pelo próprio engine e só aparecem em CommandError.payload. Os de contrato (invalid_initial_value, appointment_without_patient, missing_token, invalid_token, invalid_patient, unsupported_command — mais o obsoleto patient_change_not_allowed) fazem parte de AppErrorType, 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 mesmo patient.id que você enviou. Hosts que precisem refletir a edição no próprio cadastro devem observar o prescription-bff, não o RxEngine.


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/contracts diretamente.


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 produto

A devqaUrl vira o src do iframe e o targetOrigin do postMessage, então só é aceita como URL http/https absoluta. Todo descarte — ambiente errado ou valor inválido — explica o motivo em console.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 MessageChannel privado — após o handshake, toda a comunicação acontece pelo par port1 / port2, fora do alcance de outras windows no host. Mensagens que trafegam por um MessagePort nã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 pelo targetOrigin do postMessage, que é a URL exata do iframe (não *) — o browser só entrega a mensagem, e o port junto, se a window do iframe estiver naquela origin.
  • O token viaja nessa mesma transferência, e por isso está sob a mesma proteção: o engine:mount é postado com o targetOrigin exato do iframe, então o browser não entrega a mensagem — nem a credencial — se a window de destino não estiver naquela origin. Ele não trafega por window.postMessage com '*' em nenhum momento, e depois do handshake nunca mais sai da window.
  • Não há allowlist de origin do integrador (não existe um allowedOrigin na config). Ela seria redundante: o targetOrigin acima 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 --noEmit

Exemplo de diagrama

O npm não renderiza diagramas mermaid nativamente — por isso este é servido como imagem via mermaid.ink, que renderiza tanto no npm quanto no GitHub.

Exemplo de diagrama mermaid (graph TD)


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 changeset

Os 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:

  1. Resolve a versão. Se há changesets pendentes, aplica-os com changeset version (atualiza o version no package.json do engine — não edite à mão — e escreve o CHANGELOG.md). Se não há changeset pendente, o input bump do workflow (patch por padrão) gera um para o engine, permitindo cortar o release mesmo assim. Havendo changeset commitado, o input é ignorado (evita bump duplo).
  2. Commita o bump como chore: release packages.
  3. Builda só o engine — turbo run build --filter=@afyadigital/receitapro-engine.
  4. Publica com pnpm changeset publish — sobe os pacotes não-privados (hoje, só o engine) e cria a tag @afyadigital/receitapro-engine@<versão>.
  5. *Faz push