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

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. Generic typed transport (send / request / on) plus a content-side last-value context API (getContext / onContextChange) seeded from init.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

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 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| 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
  // 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 handshake

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 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)
  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 and, when the host supports context, payload.context (the first snapshot). Replies use 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, caches payload.context (or an empty snapshot if omitted), and thereafter posts with targetOrigin = pinnedParentOrigin. The channel is open and both sides emit ready. getContext() / onContextChange can now answer immediately.

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):

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 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 seeds PaywallContext on init (context on createHostBridge); the iframe renders custom.first_name, device_meta.locale, and a formatted price via onContextChange (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 a request; the content replies and sends a message back; all results render into the DOM (status, ping response, content-ready payload, 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_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 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 | hostcontent.send("context", snapshot) via HostBridge.setContext | | Developer read | window.RC.getContext() / window.RC.onContextChange()not on("context") or requestContext | | completeStep / rc:step-complete | hostcontent.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: 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 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):

  • 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, 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.