@revenuecat/workflow-web-components-sdk
v0.1.8
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.
Maintainers
Keywords
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 aswindow.RCby a versioned, CDN-distributable IIFE build).
Scope. Generic typed transport (
send/request/on) plus a content-side last-value context API (getContext/onContextChange) seeded frominit.payload.context. Other business messages (rc:step-complete, permissions, manifest, …) are still layered on top of the transport — see Mapping to the future message set.
Table of contents
- Install & build
- Architecture
- Host API
- Content API
- Wire format (envelope)
- Handshake
- Security model
- Versioning & injection
- Releasing
- Demo
- Tests
- Mapping to the future message set
- Embedding in native SDKs (iOS/Android)
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-tripnpm 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 export). CDN artifact, dynamic-imported by revenuecat-app. |
| dist/sdk/v1/rc-host.iife.js | IIFE (minified) | Self-contained host build (window.RCHost.createHostBridge). CDN artifact, loaded via <script> 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| coreBoth 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
// context: samplePaywallContext, // first snapshot; included on init
// 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();
bridge.setContext(nextSnapshot); // full PaywallContext replace (not merge)
// ...
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. |
| context? | PaywallContext | First snapshot, sent on init as payload.context. Omit if this host does not support context. Do not also send("context") for this first value — use setContext only for later replacements. |
| 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).
setContext(ctx) stores the latest snapshot. Before the channel opens it is
included on init; after ready it posts a context message (CONTEXT_MESSAGE)
with the full snapshot (replace, not merge).
Content API
import { createContentBridge } from "@revenuecat/workflow-web-components-sdk/content";
const rc = createContentBridge({ protocolVersion: 1 }); // auto-starts the handshake
rc.onContextChange((ctx) => {
// fires with the init snapshot, then again on later host setContext()
const price = ctx.package?.products[0]?.price;
document.querySelector("#price")!.textContent =
rc.utils.formatPrice(price, ctx.device_meta.locale);
});
const ctx = await rc.getContext(); // same cached snapshot; waits for handshakeIn 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 content bridge (includes RC.utils.formatPrice)
window.RC.onContextChange((ctx) => {
const price = ctx.package?.products[0]?.price;
document.querySelector("#price").textContent =
window.RC.utils.formatPrice(price, ctx.device_meta.locale);
});
</script>onContextChange also fires straight away with the current values (on a microtask
if already ready), so most components can use it on its own and skip getContext().
Do not register both with the same render function — that double-paints. getContext()
is for one-shot reads (e.g. a click handler). Format prices with
rc.utils.formatPrice(price, ctx.device_meta.locale) (accepts en-US and still
normalizes underscores like en_US for Intl); insert text with textContent, not innerHTML.
A host that omits init.payload.context does not support context: getContext()
resolves an empty-shaped snapshot (custom: {}, offering: null, packages: [],
package / selected_package null, inputs: {}, device_meta with empty
locale and zero updated_at) and onContextChange fires once with that. There
is no timeout and no requestContext developer API.
PaywallContext shape
Context carries SDK-native Offering, Package, and Product objects — not
pre-formatted product variable strings like price_per_period. Format raw
price: { amount, currency } with utils.formatPrice and device_meta.locale.
| Field | Shape |
| --- | --- |
| custom | Record<string, string \| number \| boolean> |
| offering | { identifier, display_name } or null |
| packages | PaywallPackage[] with identifier, display_name, products[] |
| package / selected_package | Same Package shape or null |
| inputs | { [fieldId]: string \| string[] \| null } |
| workflow? | { workflow_id, step_id, step_type, screen_type[] } — funnel only |
| device_meta | { is_preview, locale, dark_mode, updated_at } — always present; locale is BCP 47 with hyphens (en-US) |
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. |
Content-only context API
| Method | Description |
| --- | --- |
| getContext(): Promise<PaywallContext> | Waits for init, then resolves the cached snapshot. Never hangs after handshake; rejects on handshake failure or destroy(). |
| onContextChange(cb): () => void | Fires with the cache after init (microtask if already open), then on every later context message. Returns unsubscribe. |
| utils.formatPrice(price, locale): string | Formats raw { amount, currency } with Intl; accepts en-US and still normalizes underscores. Returns "" when price is missing. |
The first snapshot rides on init as payload: { context }. Later replacements are
fire-and-forget messages with type: "context" (CONTEXT_MESSAGE) and payload
equal to the full snapshot. Hosts must not send that first snapshot twice.
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, optional payload.context)
Note over C: pin parent origin from event.origin<br/>store component_id, cache context, emit ready
Note over C,H: channel open: send / request / on (both ways)- Content →
connect. On load the content postsconnect(carrying itsprotocol_version) towindow.parent. It does not yet know the parent origin, so this first frame usestargetOrigin: "*"; it carries no secrets (only the channel discriminator and version). The content re-sendsconnectperiodically until it receivesinit(handles host-not-ready races). - Host →
init/reject. The host validatesevent.source === iframe.contentWindowand the sender origin againstallowedOrigin(accepting"null"only whenallowInsecureOpaqueOriginis set). If theprotocol_versionis not supported it repliesrejectand does not open. Otherwise it repliesinitwith the assignedcomponent_idand, when the host supports context,payload.context(the first snapshot). Replies usetargetOrigin = allowedOrigin(falling back to"*"only for an accepted opaque origin). - Content pins the parent. On
initthe content pins the parent origin fromevent.origin, storescomponent_id, cachespayload.context(or an empty snapshot if omitted), and thereafter posts withtargetOrigin = pinnedParentOrigin. The channel is open and both sides emit ready.getContext()/onContextChangecan now answer immediately.
Security model
Every inbound frame is validated on both sides; invalid or foreign frames are ignored, never thrown:
event.sourcemust be the expected window (host:iframe.contentWindow; content:window.parent).event.originmust match the pinned/allowed origin.channelmust equal"rc-web-components".- Once open,
component_idmust 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 ESM alias (short cache)
https://sdk.revenuecat-static.com/v<major>/rc-host.iife.js ← stable host IIFE alias (short cache)
https://sdk.revenuecat-static.com/releases/<semver>/rc-sdk.js ← immutable content (1y)
https://sdk.revenuecat-static.com/releases/<semver>/rc-host.js ← immutable host ESM (1y)
https://sdk.revenuecat-static.com/releases/<semver>/rc-host.iife.js ← immutable host IIFE (1y)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">toindex.htmlonce at upload, ahead of the bundle's own scripts, sowindow.RCexists 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 CSPscript-src(infrastructurecore-infra/workflow_components).
Loading the host build
There are two host CDN artifacts:
ESM (rc-host.js) — for dynamic import() (e.g. revenuecat-app previewer):
const { createHostBridge } = await import(
`https://sdk.revenuecat-static.com/v${protocol_version}/rc-host.js`
);IIFE (rc-host.iife.js) — for classic <script src> (Metro-safe; purchases-ui-js).
The IIFE assigns window.RCHost = { createHostBridge }:
const script = document.createElement("script");
script.src = `https://sdk.revenuecat-static.com/v${protocol_version}/rc-host.iife.js`;
script.async = true;
script.onload = () => {
const createHostBridge = window.RCHost?.createHostBridge;
// …
};
document.head.appendChild(script);Embedding apps must allow https://sdk.revenuecat-static.com in their CSP
script-src. 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 immutablereleases/<semver>/rc-sdk.js+rc-host.js, overwrites the rollingv<major>/aliases, mergessdk.json(preserving other majors), and invalidates the aliases +/sdk.json. The localdemo/simulates a self-contained round-trip (itswrapper.htmlresolves a generatedsdk.jsonfor 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:
- Bump the version — update both
package.jsonandpackage-lock.json(ornpm ciin CI fails):
npm version <new-version> --no-git-tag-version- Open a PR.
ci.ymlruns the full test suite on it. - Merge to
main.publish.ymlruns the same tests first and, only if they pass, publishes the immutablereleases/<new-version>/rc-sdk.jsand updatessdk.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
publishjobneedsthetestjob). - A new protocol major (e.g.
2) is not just a version bump. Thesdk.jsonkey comes from the build'sdist/sdk/v<major>/directory, so shippingv2requires a code change: emitdist/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 earlierreleases/<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=1Open the parent URL. The page:
- creates an
<iframe>pointing athttp://localhost:5276/wrapper.html?sdk=1; wrapper.html(served from the:5276origin) reads?sdk=1, fetches the localsdk.json, resolves that major'scontent_sdk, and injects that build, which auto-initswindow.RC(the demo server generatessdk.jsonfrom the build);- the host seeds
PaywallContextoninit(contextoncreateHostBridge); the iframe renderscustom.first_name,device_meta.locale, and a formatted price viaonContextChange(same path as a real custom component); - edit the Context JSON textarea on the parent and click Push context to
call
setContext()with the full snapshot at any time; - the host
sends a message and makes arequest; the content replies and sends a message back; all results render into the DOM (status,pingresponse,content-readypayload, context fields, and an event log).
npm run test:e2e (Playwright) asserts the handshake, ping round-trip, init context,
and a setContext locale update in real Chromium.
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_versionmatch and mismatch (reject);destroy()listener cleanup. - Integration — jsdom (always active in
npm test): a faithful two-window simulation wires two fake windows'postMessagetogether, honouringevent.source/event.origin/targetOriginacross two origins, and drives the realcreateHostBridge+createContentBridgethrough the full handshake, a bidirectional message, and a request/response. This guaranteesnpm testalways proves the end-to-end round-trip. - Integration — Playwright (
npm run test:e2e): boots the two demo servers on alternate ports, loadsparent.htmlin 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 remains message-agnostic for arbitrary type strings. Paywall
context is the first concrete app API layered on top:
| Concept | Transport usage |
| --- | --- |
| First context snapshot | init.payload.context (not a context message — that would race) |
| Later context updates | host → content.send("context", snapshot) via HostBridge.setContext |
| Developer read | window.RC.getContext() / window.RC.onContextChange() — not on("context") or requestContext |
| completeStep / rc:step-complete | host ← content.send("rc:step-complete", ...) (not implemented here) |
| permissions / manifest negotiation | exchanged during/after handshake via request |
Because kind (framing) and type (app message) are separate, context updates
did not require a new envelope kind.
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:createHostBridgestays 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 kinds, 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. Supporting hosts include the first context snapshot as
payload.context on that init; omitting it means context is unsupported. Later
updates are kind: "message", type: "context" with the full snapshot as payload.
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 /
getContext / onContextChange 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","payload":{"context":{/* PaywallContext */}}}
// later updates:
// {"channel":"rc-web-components","protocol_version":1,"kind":"message","component_id":"checkout-1","type":"context","payload":{/* full snapshot */}}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):
- iOS —
WKUserScript(source: rcSdkJs, injectionTime: .atDocumentStart, forMainFrameOnly: true)onconfiguration.userContentController. - Android —
WebViewCompat.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, include payload.context on init when the host
supports context, push later snapshots as type: "context" messages, and correlate
request / response. For reference/testing the content entry also exports
detectNativeHost, NativeTransport, WindowTransport, and the NATIVE_CHANNEL /
NATIVE_RECEIVE_GLOBAL constants.
