# @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](#mapping-to-the-future-message-set).

---

## Table of contents

- [Install & build](#install--build)
- [Architecture](#architecture)
- [Host API](#host-api)
- [Content API](#content-api)
- [Wire format (envelope)](#wire-format-envelope)
- [Handshake](#handshake)
- [Security model](#security-model)
- [Versioning & injection](#versioning--injection)
- [Releasing](#releasing)
- [Demo](#demo)
- [Tests](#tests)
- [Mapping to the future message set](#mapping-to-the-future-message-set)
- [Embedding in native SDKs (iOS/Android)](#embedding-in-native-sdks-iosandroid)

---

## Install & build

Requires Node ≥ 18 and npm.

```bash
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` 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:

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

---

## Architecture

```mermaid
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

```ts
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

```ts
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](#versioning--injection)):

```html
<!-- 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.):

```ts
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

```mermaid
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 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">` 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

There are **two** host CDN artifacts:

**ESM** (`rc-host.js`) — for dynamic `import()` (e.g. revenuecat-app previewer):

```ts
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 }`:

```ts
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:

```jsonc
{
  // 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](#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):

```bash
npm version <new-version> --no-git-tag-version
```

2. Open a PR. `ci.yml` runs the full [test suite](#tests) on it.
3. 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:

```bash
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 `send`s 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

```bash
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` | `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:
> `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**:

```swift
// iOS (Swift)
webView.evaluateJavaScript("window.__rcWebComponentsReceive(\(jsonString))")
```

```kotlin
// 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.

```jsonc
// 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)` on `configuration.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.
