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

@revenuecat/workflow-web-components-sdk

v0.1.4

Published

Typed, extensible postMessage transport bridge for RevenueCat embedded web components (Funnels web_view). Ships both the host and content sides of the bridge from one package.

Downloads

266

Readme

@revenuecat/workflow-web-components-sdk

The message-transport layer ("bridge SDK") for RevenueCat's embedded web components in Funnels (the web_view paywall component).

A sandboxed <iframe> hosts an untrusted developer bundle. The parent page (the host, living inside purchases-ui-js) and the iframe content (the content client) talk to each other over window.postMessage. This package ships both sides of that bridge from one codebase:

  • createHostBridge(...) — the trusted parent-page side.
  • createContentBridge(...) — the untrusted iframe side (also auto-initialized as window.RC by a versioned, CDN-distributable IIFE build).

Scope — Phase 1a. This is a generic, typed, extensible transport only. It can carry arbitrary typed messages in both directions, but it deliberately defines no concrete business messages (no rc:step-complete, requestVariables, completeStep, permissions, manifest, storage-blocking, etc.). Those are defined by a later RFC and layered on top of send / request / on — see Mapping to the future message set.


Table of contents


Install & build

Requires Node ≥ 18 and npm.

npm install
npm run build      # tsup (JS) + tsc (types)
npm run typecheck  # tsc --noEmit
npm test           # vitest: unit + jsdom cross-origin round-trip

npm run build produces:

| Output | Format | Purpose | | --- | --- | --- | | dist/host.js + dist/host.d.ts | ESM | Host bridge — typed package export for local dev/types. | | dist/content.js + dist/content.d.ts | ESM | Content bridge factory (advanced / non-injected use). | | dist/sdk/v1/rc-sdk.js | IIFE (minified) | Self-contained content build (window.RC). CDN artifact, injected into bundle index.html. | | dist/sdk/v1/rc-host.js | ESM (minified) | Self-contained host build (createHostBridge). CDN artifact, dynamic-imported by purchases-ui-js. |

Package entry points:

"exports": {
  ".":         { "types": "./dist/host.d.ts",    "import": "./dist/host.js" },
  "./content": { "types": "./dist/content.d.ts", "import": "./dist/content.js" }
}

Architecture

flowchart LR
  subgraph hostSide ["Host page (purchases-ui-js)"]
    host["createHostBridge()<br/>validates origin / source<br/>assigns component_id"]
  end
  subgraph frameSide ["Sandboxed iframe (content)"]
    content["window.RC = createContentBridge()<br/>injected into index.html by khepri<br/>developer bundle uses RC"]
  end
  core["Shared transport core (src/core/)"]
  host <-->|"window.postMessage — typed envelopes"| content
  host -.->|uses| core
  content -.->|uses| core

Both bridges share one core (src/core/base-bridge.ts) that owns handler registration, request/response correlation, the ready lifecycle, an outbound queue (so send/request issued before the channel opens are buffered and flushed on ready), and generic inbound dispatch. Each side only implements its half of the handshake and its origin/source-validation policy.


Host API

import { createHostBridge } from "@revenuecat/workflow-web-components-sdk";

const iframe = document.querySelector("iframe")!;

const bridge = createHostBridge({
  iframe,
  allowedOrigin: "https://sdk.example.com", // the exact iframe origin
  componentId: "checkout-1",                // assigned to the content on init
  protocolVersion: 1,                       // optional, defaults to 1
  // allowInsecureOpaqueOrigin: true,       // only for opaque ("null") sandbox origins
});

// Register handlers (a returned value answers a matching `request`).
const off = bridge.on("some-event", (payload, ctx) => {
  if (ctx.kind === "request") return { ok: true };
});

bridge.onReady(() => {
  bridge.send("hello", { at: Date.now() });               // fire-and-forget
  bridge.request("do-thing", { n: 1 }).then(console.log); // awaits a response
});

await bridge.whenReady();
// ...
off();
bridge.destroy();

createHostBridge(config, environment?)

| Field | Type | Notes | | --- | --- | --- | | iframe | HTMLIFrameElement | The frame hosting the content bridge. | | allowedOrigin | string | Exact origin allowed to talk to this host. | | componentId | string | Identity assigned to the content during the handshake. | | protocolVersion? | number | Defaults to 1. A mismatched content version is refused. | | allowInsecureOpaqueOrigin? | boolean | Accept the opaque origin "null" (sandboxed frame without allow-same-origin); replies then use targetOrigin: "*". Weakens isolation — opt in deliberately. |

environment.window is an optional advanced/testing override for the window the bridge listens on (defaults to the global window).

Content API

import { createContentBridge } from "@revenuecat/workflow-web-components-sdk/content";

const rc = createContentBridge({ protocolVersion: 1 }); // auto-starts the handshake

rc.on("ping", (payload) => ({ pong: true, echo: payload })); // answers host requests
await rc.whenReady();
rc.send("content-ready", { at: Date.now() });

In production the content side is not imported — it is injected as a global. khepri adds a single stable <script> to the bundle's index.html once at upload (see Versioning & injection):

<!-- added once to index.html by khepri; the URL is a stable, rolling per-major alias -->
<script src="https://sdk.revenuecat-static.com/v1/rc-sdk.js"></script>
<script>
  // window.RC is a ready-to-use bridge instance
  window.RC.on("ping", (p) => ({ pong: true, echo: p }));
  window.RC.whenReady().then(() => window.RC.send("content-ready", {}));
</script>

Auto-resize (fit sizing)

The injected content build automatically reports its rendered content-box size so a host fit/auto container can size to the content (the host cannot measure a cross-origin iframe itself). It observes the document with a ResizeObserver and emits a resize message (RESIZE_MESSAGE) carrying { width, height } in CSS px — on ready and on every reflow. No developer code is required; pass createContentBridge({ autoResize: false }) to opt out. Additive within protocol v1: a host that does not handle resize simply ignores it.

Shared Bridge interface (both sides)

| Method | Description | | --- | --- | | on(type, handler): () => void | Register a handler; returns an unsubscribe. If the inbound frame is a request, the handler's (awaited) return value is sent back as the response. | | off(type, handler) | Remove a handler. | | send(type, payload?) | Fire-and-forget message. | | request(type, payload?, { timeoutMs = 30000 }) | Sends a request with a unique id; resolves with the matching response payload, rejects on timeout or error. | | ready(): boolean | Whether the channel is open. | | onReady(cb): () => void | Runs cb once when the channel opens (async-immediately if already open). | | whenReady(): Promise<void> | Resolves on open; rejects if the handshake is refused. | | destroy() | Removes the listener, rejects pending requests, clears all state. |

Messages sent before the channel is open are buffered and flushed on ready.


Wire format (envelope)

Every frame is a single JSON-serializable object (objects/arrays/strings/numbers/ booleans/null only — no functions, Date, Map, etc.):

interface Envelope {
  channel: "rc-web-components"; // fixed discriminator; foreign traffic is ignored
  protocol_version: number;     // wire + SDK-major + artifact selector (see below)
  kind: "connect" | "init" | "reject" | "message" | "request" | "response" | "error";
  component_id: string;         // assigned by the host; "" on the initial connect
  type?: string;                // free-form app message name (message/request/response)
  id?: string;                  // correlates a request with its response/error
  payload?: unknown;            // app payload
  error?: string;               // human-readable error (error / reject)
}

kind is transport framing; type is the application-level message name. Keeping them separate lets the transport stay message-agnostic while apps invent any type strings they need.


Handshake

sequenceDiagram
  participant C as content
  participant H as host
  C->>H: connect(protocol_version), targetOrigin *
  Note over H: validate source === iframe.contentWindow<br/>validate origin === allowedOrigin<br/>unsupported version → reject (channel not opened)
  H->>C: init(component_id, protocol_version), targetOrigin allowedOrigin
  Note over C: pin parent origin from event.origin<br/>store component_id, emit ready
  Note over C,H: channel open: send / request / on (both ways)
  1. Content → connect. On load the content posts connect (carrying its protocol_version) to window.parent. It does not yet know the parent origin, so this first frame uses targetOrigin: "*"; it carries no secrets (only the channel discriminator and version). The content re-sends connect periodically until it receives init (handles host-not-ready races).
  2. Host → init / reject. The host validates event.source === iframe.contentWindow and the sender origin against allowedOrigin (accepting "null" only when allowInsecureOpaqueOrigin is set). If the protocol_version is not supported it replies reject and does not open. Otherwise it replies init with the assigned component_id, using targetOrigin = allowedOrigin (falling back to "*" only for an accepted opaque origin).
  3. Content pins the parent. On init the content pins the parent origin from event.origin, stores component_id, and thereafter posts with targetOrigin = pinnedParentOrigin. The channel is open and both sides emit ready.

Security model

Every inbound frame is validated on both sides; invalid or foreign frames are ignored, never thrown:

  • event.source must be the expected window (host: iframe.contentWindow; content: window.parent).
  • event.origin must match the pinned/allowed origin.
  • channel must equal "rc-web-components".
  • Once open, component_id must match.

Opaque origins. A frame sandboxed without allow-same-origin reports origin "null". Opaque origins can only be addressed with targetOrigin: "*", so the host requires the explicit allowInsecureOpaqueOrigin opt-in to accept them and then replies with "*". This weakens origin isolation; prefer a real origin (the demo uses allow-same-origin so origin validation uses the real :5276).


Versioning & injection

protocol_version is the single version number — simultaneously the wire-protocol version and the SDK major version. There is no separate sdk_version. Today it is 1. A future breaking change ships 2 (built at dist/sdk/v2/...) alongside 1.

CDN layout

Every release is published to immutable, per-version paths (provenance + rollback) and to a stable, rolling per-major alias that hosts and bundles reference by major:

https://sdk.revenuecat-static.com/v<major>/rc-sdk.js         ← stable content alias (short cache)
https://sdk.revenuecat-static.com/v<major>/rc-host.js        ← stable host alias (short cache)
https://sdk.revenuecat-static.com/releases/<semver>/rc-sdk.js    ← immutable content build (1y immutable)
https://sdk.revenuecat-static.com/releases/<semver>/rc-host.js   ← immutable host build (1y immutable)

Because references use the stable v<major>/ alias, shipping a new build just overwrites the alias (short cache + CloudFront invalidation) — no stored bundle HTML is ever rewritten. Immutable releases are append-only and never overwritten (re-publishing changed bytes under an existing version is refused).

Injecting the content build

The content build must run in the same document as the developer bundle so the bundle can call window.RC. A host page cannot inject into a cross-origin sandboxed iframe, so injection happens on the bundle's own index.html:

  • khepri adds one synchronous <script src=".../v<major>/rc-sdk.js"> to index.html once at upload, ahead of the bundle's own scripts, so window.RC exists first. The URL is the stable alias, so SDK updates never touch stored HTML.
  • The components CDN (*.components.revenuecat-static.com) permits this cross-origin script through its CSP script-src (infrastructure core-infra/workflow_components).

Loading the host build

purchases-ui-js dynamic-imports the host build at runtime, selected by protocol_version, from the stable alias:

const { createHostBridge } = await import(
  `https://sdk.revenuecat-static.com/v${protocol_version}/rc-host.js`
);

Embedding apps must allow https://sdk.revenuecat-static.com in their CSP script-src for this module import. The handshake re-checks protocol_version end to end; a version the host does not support aborts the channel.

sdk.json (provenance index)

sdk.json is no longer on the runtime path (resolution uses the stable aliases). It is kept as a tooling/provenance index recording the immutable release each major currently points at:

{
  // key = protocol major, from the build dir dist/sdk/v<major>/ (v1 -> "1")
  "1": {
    "version": "0.1.0",                         // package.json semver of that release
    "content_sdk": "releases/0.1.0/rc-sdk.js",  // immutable build the v1 content alias mirrors
    "host_sdk": "releases/0.1.0/rc-host.js"     // immutable build the v1 host alias mirrors
  }
}

Publishing a major merges into the manifest, so other majors are preserved.

Production publishing runs in .github/workflows/publish.yml: it uploads the immutable releases/<semver>/rc-sdk.js + rc-host.js, overwrites the rolling v<major>/ aliases, merges sdk.json (preserving other majors), and invalidates the aliases + /sdk.json. The local demo/ simulates a self-contained round-trip (its wrapper.html resolves a generated sdk.json for convenience).


Releasing

Publishing is automated by .github/workflows/publish.yml (see Versioning & injection for what lands on the CDN). To cut a release of the current protocol major:

  1. Bump the version — update both package.json and package-lock.json (or npm ci in CI fails):
npm version <new-version> --no-git-tag-version
  1. Open a PR. ci.yml runs the full test suite on it.
  2. Merge to main. publish.yml runs the same tests first and, only if they pass, publishes the immutable releases/<new-version>/rc-sdk.js and updates sdk.json's entry for that major to point at it (invalidating /sdk.json). Previous releases are never removed (append-only).

Notes:

  • Tests gate the publish — a failing test blocks the release (the publish job needs the test job).
  • A new protocol major (e.g. 2) is not just a version bump. The sdk.json key comes from the build's dist/sdk/v<major>/ directory, so shipping v2 requires a code change: emit dist/sdk/v2/ and bump the protocol-version constant. A same-major bugfix/feature is just the version bump above.
  • Rollback isn't automated: since releases are immutable and there's no alias, re-point sdk.json's entry back to an earlier releases/<semver>/… (publish an older build, or amend the manifest).

Demo

A real cross-origin round-trip across two origins:

npm run build
npm run demo
# Parent (host)     ->  http://localhost:5275/parent.html?content=5276
# Content (wrapper) ->  http://localhost:5276/wrapper.html?sdk=1

Open the parent URL. The page:

  • creates an <iframe> pointing at http://localhost:5276/wrapper.html?sdk=1;
  • wrapper.html (served from the :5276 origin) reads ?sdk=1, fetches the local sdk.json, resolves that major's content_sdk, and injects that build, which auto-inits window.RC (the demo server generates sdk.json from the build);
  • the host sends a message and makes a request; the content replies and sends a message back; all results render into the DOM (status, ping response, content-ready payload, and an event log).

Two distinct origins (:5275:5276) exercise real cross-origin transport and origin validation. Ports default to 5275 (host) / 5276 (content) and can be overridden with RC_DEMO_PARENT_PORT / RC_DEMO_CONTENT_PORT.


Tests

npm test          # vitest: unit + jsdom cross-origin round-trip (always on)
npm run test:e2e  # Playwright: real Chromium cross-origin round-trip
  • Unit (vitest + jsdom): envelope build/parse and rejection of malformed/ foreign frames; origin + source validation (accept valid, reject wrong origin, reject wrong source window); request/response correlation + timeout; the full handshake state machine including protocol_version match and mismatch (reject); destroy() listener cleanup.
  • Integration — jsdom (always active in npm test): a faithful two-window simulation wires two fake windows' postMessage together, honouring event.source / event.origin / targetOrigin across two origins, and drives the real createHostBridge + createContentBridge through the full handshake, a bidirectional message, and a request/response. This guarantees npm test always proves the end-to-end round-trip.
  • Integration — Playwright (npm run test:e2e): boots the two demo servers on alternate ports, loads parent.html in real Chromium, and asserts the DOM shows a successful connect + echoed request/response + content message. Requires the browser once: npx playwright install chromium.

Both integration paths are implemented and passing.


Mapping to the future message set

The transport is intentionally message-agnostic today. The forthcoming RFC message set will be built on top of this transport with no transport changes — each concrete message becomes a type string carried by message / request / response. For example (illustrative, not implemented here):

| Future concept | Likely transport usage | | --- | --- | | requestVariables | hostcontent.request("requestVariables", ...) | | completeStep / rc:step-complete | hostcontent.send("rc:step-complete", ...) | | permissions / manifest negotiation | exchanged during/after handshake via request |

Because kind (framing) and type (app message) are separate, none of the above requires changing the envelope or the handshake.


Embedding in native SDKs (iOS/Android)

Supported. The content side auto-detects a native WebView and speaks the same protocol over a native channel. The wire format, handshake (connect / init / reject), component_id, protocol_version, and request/response correlation are identical to web — only the low-level transport changes. This package does not ship a native host: createHostBridge stays web-only; native code implements the host to the wire contract below.

On mobile there is no parent web page and no iframe: the native app is the host and renders a WebView, and the content bundle runs inside it and uses window.RC exactly as on web. createContentBridge() (and the injected window.RC) selects its transport automatically — NativeTransport when a native host channel is detected, otherwise the web WindowTransport.

The exact JS ↔ native contract

Channel name (constant NATIVE_CHANNEL): rcWebComponents. Inbound global (constant NATIVE_RECEIVE_GLOBAL): window.__rcWebComponentsReceive. (These are distinct from the on-the-wire envelope channel, which stays "rc-web-components".)

Detection — how the content decides it is native:

  • iOS if typeof window.webkit?.messageHandlers?.rcWebComponents?.postMessage === "function".
  • Android if typeof window.rcWebComponents?.postMessage === "function".
  • otherwise plain web.

JS → native (outbound). The SDK sends JSON.stringify(frame)always a string (for Android @JavascriptInterface / WebMessageListener compatibility):

  • iOS: window.webkit.messageHandlers.rcWebComponents.postMessage(json)
  • Android: window.rcWebComponents.postMessage(json)

native → JS (inbound). The SDK installs window.__rcWebComponentsReceive(data); native calls it (via evaluateJavaScript / evaluateJavascript) with either a JSON string (it is parsed) or an already-parsed object:

// iOS (Swift)
webView.evaluateJavaScript("window.__rcWebComponentsReceive(\(jsonString))")
// Android (Kotlin)
webView.evaluateJavascript("window.__rcWebComponentsReceive($jsonString)", null)

Handshake (unchanged, run over the native channel). The content posts connect (with its protocol_version); the native host replies init carrying component_id + protocol_version (or reject for an unsupported version) via __rcWebComponentsReceive. The content retries connect until it gets a reply, so a not-yet-ready native host is handled. After init the channel is open and send / request / on behave exactly as on web.

// JS -> native: postMessage(json) where json = JSON.stringify(frame):
//   {"channel":"rc-web-components","protocol_version":1,"kind":"connect","component_id":""}
// native -> JS: window.__rcWebComponentsReceive(data) with a string or object:
//   {"channel":"rc-web-components","protocol_version":1,"kind":"init","component_id":"checkout-1"}

Security model on native

The native host is the sole trusted peer: there is no window.parent and no origin to pin, so the SDK skips origin/source validation on native. It still enforces the channel discriminator (channel === "rc-web-components"), the protocol_version match, and the component_id (post-init) exactly as on web. Harden the WebView at the platform layer — scope Android's addWebMessageListener / addDocumentStartJavaScript allowedOriginRules to the RC CDN origin (e.g. https://*.components.revenuecat-static.com), validate WKScriptMessage.frameInfo on iOS, and restrict navigation and file access. On destroy() the SDK removes the window.__rcWebComponentsReceive global.

Injecting rc-sdk.js at document start

Resolve the release through the manifest (fetch sdk.json, look up String(protocol_version), take its content_sdk) and inject that same immutable build (<cdn-base>/<content_sdk>) at document-start so window.RC exists before the developer bundle runs — exactly as on web (there is no /v<major>/ alias):

  • iOSWKUserScript(source: rcSdkJs, injectionTime: .atDocumentStart, forMainFrameOnly: true) on configuration.userContentController.
  • AndroidWebViewCompat.addDocumentStartJavaScript(webView, rcSdkJs, allowedOriginRules) (AndroidX).

The native host implements the protocol

The host half is implemented natively (mirroring createHostBridge): parse/emit the JSON envelope, run the connect / init handshake, assign component_id, reject an unsupported protocol_version, and correlate request / response. For reference/testing the content entry also exports detectNativeHost, NativeTransport, WindowTransport, and the NATIVE_CHANNEL / NATIVE_RECEIVE_GLOBAL constants.