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.0.1

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, 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 (add:medication, update:token, search:vaccine, ...) e um EventEmitter para escutar eventos de ciclo de vida (app:init, token:expired, ...).

O que o engine não faz

  • Não valida payloads. É transporte puro: não importa Zod, não conhece DTOs, não sabe o que é "medication" ou "vaccine". Toda validação de domínio vive no @receitapro/web.
  • Não enfileira commands antes do render() ou após unmount() — falhas explícitas em vez de bugs silenciosos.
  • Não conhece o host além da allowedOrigin declarada e do token opaco que recebe.

Modelo conceitual

sequenceDiagram
    autonumber
    participant Host as Host (sua aplicação)
    participant Engine as RxEngine
    participant Iframe as iframe (rx-web)

    Host->>Engine: new RxEngine({ container, allowedOrigin,<br/>token, initialValue? })
    Host->>Engine: render()
    Engine->>Iframe: iframe.src = IFRAME_URL<br/>(carrega rx-web)
    Engine->>Iframe: window.postMessage('engine:mount', [port2])
    Note over Engine,Iframe: Único window.postMessage do contrato —<br/>após isto, tudo pelo MessageChannel privado<br/>(port1 ↔ port2, cross-origin, isolado da window)

    Host->>Engine: await commands.add('medication', payload)
    Engine->>Iframe: port1.postMessage({ type, id, payload })
    Iframe-->>Engine: port2.postMessage({ event, id, payload })
    Engine-->>Host: Promise<Result> resolve(payload)

    Iframe-->>Engine: { event: 'app:init' } / 'token:expired' / ...
    Engine-->>Host: engine.events.listen(...) — ciclo de vida

    Host->>Engine: unmount()
    Engine->>Iframe: port1.close() + iframe.remove()

O id que carrega cada envelope no fio ({ type, id, payload }) é detalhe interno de transporte — o engine o usa para casar request/response e nunca o expõe na API pública. Para o host, commands.add(...) é uma async function qualquer.


Instalação

Dentro do monorepo:

pnpm --filter <consumer> add @afyadigital/receitapro-engine

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

npm install @afyadigital/receitapro-engine

Uso básico

import { RxEngine } from '@afyadigital/receitapro-engine'

const container = document.getElementById('rx-container')!

const engine = new RxEngine({
  container,
  allowedOrigin: 'https://app.iclinic.com.br',
  token: 'Bearer <jwt>',
})

engine.events.listen('app:init', () => {
  // o iframe está pronto — canal aberto, stores hidratadas
})

engine.render()

Uso com initialValue (pré-hidratação)

initialValue é entregue no payload da mensagem interna engine:mount, junto com a transferência do port2. Ele é processado pelo iframe antes do primeiro render do React, eliminando o flash de tela vazia e a necessidade de despachar uma sequência de commands após o app:init.

import { RxEngine } from '@afyadigital/receitapro-engine'

const engine = new RxEngine({
  container: document.getElementById('rx-container')!,
  allowedOrigin: 'https://app.iclinic.com.br',
  token: 'Bearer <jwt>',
  initialValue: {
    patient: { id: 'pac-001', name: 'Maria Silva', age: 42 },
    medications: [{ id: 'med-001', name: 'Amoxicilina 500mg', dosage: '1 cp 8/8h', quantity: 21 }],
    exams: [{ id: 'exam-001', name: 'Hemograma completo' }],
  },
})

engine.render()

O engine não inspeciona initialValue em runtime (transporte puro). Para DX, o host importa o tipo InitialValue do próprio @afyadigital/receitapro-engine — que re-exporta de @rx/contracts (dependência transitiva, host não instala separadamente). Tipos são apagados no build; o bundle runtime do engine permanece sem referência a @rx/contracts.

import type { InitialValue } from '@afyadigital/receitapro-engine'
import { RxEngine } from '@afyadigital/receitapro-engine'

const initialValue: InitialValue = {
  patient: { id: 'pac-001', name: 'Maria Silva' },
  medications: [{ id: 'med-001', name: 'Amox', dosage: '8/8h', quantity: 21 }],
}

Ciclo de vida

flowchart TD
    A[new RxEngine&#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/>commands podem ser enviados]
    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 G fill:#dcfce7,stroke:#22c55e
    style H fill:#fee2e2,stroke:#ef4444

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').


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 Promiseawait direto, Promise.all([...]) para paralelismo:

engine.commands.add<T>(scope, payload)     // Promise<T> — "add:<scope>"
engine.commands.update<T>(scope, payload)  // Promise<T> — "update:<scope>"
engine.commands.search<T>(scope, payload?) // Promise<T> — "search:<scope>"

Cada chamada:

  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 (MedicationPayload, VaccinePayload, ExamPayload, InitialValue, PatientInitial) — re-exportados de @rx/contracts como dependência transitiva.

Uso com await

import type { MedicationPayload } from '@afyadigital/receitapro-engine'
import { CommandError } from '@afyadigital/receitapro-engine'

try {
  const medication = await engine.commands.add<MedicationPayload>('medication', {
    id: 'med-001',
    name: 'Amoxicilina 500mg',
    dosage: '1 cp 8/8h',
    quantity: 21,
  })
  console.log('medicamento adicionado:', medication)
} catch (err) {
  if (err instanceof CommandError) {
    // err.payload contém { type: 'invalid_payload' | 'internal_error' | ..., ... }
    console.error('falhou:', err.payload)
  }
}

Paralelismo natural

const [medication, vaccine] = await Promise.all([
  engine.commands.add('medication', medPayload),
  engine.commands.add('vaccine', vacPayload),
])

Eventos de ciclo de vida (app:init, token:expired, etc.) não correspondem a um command e continuam sendo entregues via engine.events.listen(...). A Promise API substitui apenas a correlação command ↔ resposta.

Catálogo de commands

| Método | type no envelope | Payload típico | | ------------------------------------ | ------------------- | --------------------------------- | | commands.add('medication', ...) | add:medication | MedicationDto | | commands.add('vaccine', ...) | add:vaccine | VaccineDto | | commands.add('exam', ...) | add:exam | ExamDto | | commands.update('token', ...) | update:token | string (Bearer ...) | | commands.update('config', ...) | update:config | { fullscreen?: boolean } | | commands.update('medication', ...) | update:medication | Partial<MedicationDto> & { id } | | commands.search('medication', ...) | search:medication | { query?, form? } | | commands.search('vaccine', ...) | search:vaccine | { ageGroup? } | | commands.search('exam', ...) | search:exam | { category? } |

A lista de scopes válidos é definida pelo commandSchema no @receitapro/web. Um scope desconhecido ainda assim trafega pelo canal — o iframe responde com app:error (invalid_payload) e a Promise rejeita com CommandError.

Eventos de retorno

A coluna id? indica se o evento ecoa o id interno do envelope — quando sim, resolve/rejeita a Promise correspondente; quando não, é entregue apenas via engine.events.listen(...).

| Evento | Quando | id? (transporte) | | --------------------- | ------------------------------------- | --------------------- | | app:init | Canal pronto após handshake | não | | app:error | ZodError ou erro runtime | sim, quando extraível | | medication:added | Sucesso de add:medication | sim | | medication:updated | Sucesso de update:medication | sim | | medication:searched | Sucesso de search:medication | sim | | vaccine:added | Sucesso de add:vaccine | sim | | exam:added | Sucesso de add:exam | sim | | token:updated | Sucesso de update:token | sim | | token:expired | Detecção de 401/403 nas APIs internas | não | | prescription:saved | Save concluído | sim |

Sobre a coluna Quando: descreve o gatilho que faz o rx-web emitir o evento — em geral, sucesso de um command equivalente. Eventos sem id (ex: app:init, token:expired) não estão amarrados a uma chamada de commands.*; o host os consome via engine.events.listen(...).


API

class RxEngine

constructor(config: RxEngineConfig)

| Campo | Tipo | Obrigatório | Descrição | | --------------- | -------------------------------------------- | ----------- | ----------------------------------------------------------------------------- | | container | HTMLElement | ✓ | Elemento DOM onde o iframe será inserido (appendChild). | | allowedOrigin | string | ✓ | Origin pública do host integrador. Ex: https://app.iclinic.com.br. | | token | string | ✓ | JWT (ou token equivalente) repassado ao iframe. | | initialValue? | unknown | — | Estado inicial opcional para hidratar as stores antes do primeiro render. | | env? | 'production' \| 'staging' \| 'development' | — | Override explícito de ambiente. Default: process.env.RX_ENVproduction. |

Lança Error se container, allowedOrigin ou token estiverem ausentes.

render(): void

Cria o iframe, instala o MessageChannel e registra o listener de load que disparará o handshake engine:mount. Idempotente — chamadas subsequentes são no-op.

unmount(): void

Remove o iframe do DOM, fecha o port1, remove o listener de load e limpa todos os event handlers. Idempotente e seguro para chamar antes de render().

events: EngineEventEmitter

Dispatcher de eventos recebidos pelo port1. Use engine.events.listen(eventName, handler) (ver abaixo).

commands: Commands

API de envio de commands ao iframe. Cada método retorna uma Promise<T> que resolve com o payload do evento de resposta ou rejeita com CommandError:

| Método | Retorno | | ------------------------------------- | ------------ | | commands.add<T>(scope, payload) | Promise<T> | | commands.update<T>(scope, payload) | Promise<T> | | commands.search<T>(scope, payload?) | Promise<T> |

Antes de render() (ou após unmount()) chamadas rejeitam imediatamente com CommandError (type: 'no_active_channel') e warn em dev. No unmount(), Promises pendentes rejeitam com CommandError (type: 'channel_closed').

CommandError extends Error

Lançado pela rejeição das Promises de commands.*. Carrega:

| Campo | Tipo | Conteúdo | | --------- | --------- | ---------------------------------------------------------------------------------------------------- | | message | string | Resumo textual — extraído de payload.message/payload.type ou um default genérico. | | payload | unknown | Payload original do app:error ({ type, errors?, message?, ... }) ou marcadores locais do engine. |

Tipos típicos de payload.type: invalid_payload, internal_error, invalid_initial_value (vindos do receptor); no_active_channel, channel_closed (locais do engine).

listen(event, handler): Unsubscribe

Atalho equivalente a engine.events.listen(event, handler) — útil para encadear diretamente na instância do engine.


class EngineEventEmitter

| Método | Descrição | | -------------------------------------- | ----------------------------------------------------------------------------------- | | listen(event, handler) → Unsubscribe | Registra um handler. Retorna função de unsubscribe. | | dispatch(message) | Despacha um payload recebido pelo port1. Ignora payloads sem { event: string }. | | clear() | Remove todos os handlers (chamado pelo RxEngine.unmount()). |

Isolamento de erros: se um handler lançar, o erro é logado via console.error e os demais handlers continuam executando.


Tipos públicos (re-exportados de @rx/contracts)

| Tipo | Uso | | ------------------- | ---------------------------------------------------------------- | | InitialValue | Hidratação inicial (new RxEngine({ initialValue })) | | PatientInitial | initialValue.patient | | MedicationPayload | commands.add('medication', ...) / initialValue.medications[] | | VaccinePayload | commands.add('vaccine', ...) / initialValue.vaccines[] | | ExamPayload | commands.add('exam', ...) / initialValue.exams[] |

import { type InitialValue, type MedicationPayload } from '@afyadigital/receitapro-engine'

Re-exports são apenas tipos — apagados no build, sem custo de runtime. O host não precisa instalar @rx/contracts diretamente.


resolveIframeUrl(env?: RxEnv): string

Resolve a URL do iframe a partir de um override explícito ou de process.env.RX_ENV. Cai para production quando o valor é inválido.

import { IFRAME_URLS, resolveIframeUrl } from '@afyadigital/receitapro-engine'

resolveIframeUrl('staging') // → 'https://prescricao.staging.afya.com.br'
IFRAME_URLS.development // → 'http://localhost:3000'

Exemplo completo — host React

import { useEffect, useRef } from 'react'

import { RxEngine } from '@afyadigital/receitapro-engine'

export function PrescricaoEmbed({ token, patient }: Props) {
  const containerRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!containerRef.current) return

    const engine = new RxEngine({
      container: containerRef.current,
      allowedOrigin: window.location.origin,
      token,
      initialValue: { patient },
    })

    const off = engine.events.listen('app:init', async () => {
      // canal pronto — commands podem ser disparados como async functions
      try {
        await engine.commands.update('config', { fullscreen: true })
      } catch (err) {
        // CommandError quando o iframe responde com app:error
      }
    })

    engine.render()

    return () => {
      off()
      engine.unmount()
    }
  }, [token, patient])

  return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />
}

Segurança

  • O engine usa um MessageChannel privado — após o handshake, toda a comunicação acontece pelo par port1 / port2, fora do alcance de outras windows no host.
  • O targetOrigin do postMessage é a URL exata do iframe (não *).
  • A defesa contra clickjacking vive dentro do iframe (@receitapro/web), pois o produto é embed-from-any-origin (modelo SaaS widget).

Resolução de ambiente

| Estratégia | Resultado | | -------------------------------------------------- | ----------------------------------------------------- | | new RxEngine({ ..., env: 'staging' }) | Force staging. | | process.env.RX_ENV='development' (build do host) | Resolve para development (http://localhost:3000). | | Nada definido / valor inválido | Resolve para production. |

Variáveis de ambiente são lidas em build time pelos bundlers usuais (Vite, Webpack, Next.js).


Scripts

pnpm --filter @afyadigital/receitapro-engine build       # vite (esm + .d.ts via vite-plugin-dts)
pnpm --filter @afyadigital/receitapro-engine dev         # vite build --watch
pnpm --filter @afyadigital/receitapro-engine test        # vitest run
pnpm --filter @afyadigital/receitapro-engine test:watch  # vitest
pnpm --filter @afyadigital/receitapro-engine lint        # eslint
pnpm --filter @afyadigital/receitapro-engine type-check  # tsc --noEmit

Publicação (Changesets)

Este pacote é publicado no npm como @afyadigital/receitapro-engine via Changesets, a partir da raiz do monorepo.

  1. Ao alterar o engine, gere um changeset descrevendo a mudança (patch / minor / major) e commite o .changeset/*.md gerado junto com o PR:

    pnpm changeset
  2. Para publicar, um mantenedor dispara o workflow Release engine to npm (GitHub Actions → workflow_dispatch). Ele aplica as versões pendentes (changeset version), commita o bump, builda o engine e roda changeset publish, criando a tag @afyadigital/receitapro-engine@<versão>.

Requer o secret NPM_TOKEN (token de automação com publish no escopo @afyadigital) e permissão de escrita para o workflow. Apenas pacotes não-privados publicam — hoje, somente o engine.