@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
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 — 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 ofsend/request/on— 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). 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| 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
// 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)- 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_id, usingtargetOrigin = 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, and thereafter posts withtargetOrigin = 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.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 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">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
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 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
sends a message and makes arequest; the content replies and sends a message back; all results render into the DOM (status,pingresponse,content-readypayload, 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_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 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 | host ← content.request("requestVariables", ...) |
| completeStep / rc:step-complete | host ← content.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: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, 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):
- 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, and correlate request / response. For
reference/testing the content entry also exports detectNativeHost,
NativeTransport, WindowTransport, and the NATIVE_CHANNEL /
NATIVE_RECEIVE_GLOBAL constants.
