Riftix Console Bridge

Architecture

Message flow, security model, and key design decisions.

Overview

┌─────────────────────────────────────────────────────┐
│  Host (console.example.com)                         │
│                                                     │
│  ┌──────────────────────────────────────────────┐   │
│  │  ConsoleHostProvider                         │   │
│  │  ┌────────────┐   ┌────────────┐             │   │
│  │  │ IframeChannel│  │ IframeChannel│           │   │
│  │  │ billing    │   │ livecg     │             │   │
│  │  └─────┬──────┘   └─────┬──────┘            │   │
│  └────────│────────────────│───────────────────┘   │
│           │ postMessage     │ postMessage           │
│  ┌────────▼────────┐  ┌────▼────────────────┐      │
│  │ <iframe>        │  │ <iframe>            │      │
│  │ billing.example │  │ livecg.example      │      │
│  │ window.Riftix   │  │ window.Riftix       │      │
│  └─────────────────┘  └─────────────────────┘      │
└─────────────────────────────────────────────────────┘

Each product iframe is a separate origin. The bridge communicates purely via window.postMessage — no shared state, no shared modules, no same-origin assumptions.


Handshake

Client                               Host
  │                                    │
  │──── hello (protocolVersion=1) ────▶│  (targetOrigin="*" until host origin known)
  │         (retried every 100ms)      │
  │                                    │  validates origin allowlist
  │◀─── ready (context snapshot) ─────│
  │                                    │
  │◀─── authorization:changed ────────│  (possibly null)
  │                                    │
  │  Riftix.ready resolves             │
  1. The client sends hello every 100ms (up to 10s) until it receives ready.
  2. The host validates event.source === iframe.contentWindow and event.origin === registeredOrigin. Unknown origins are silently dropped.
  3. ready carries the initial HostContext snapshot so the client has theme/locale before its first paint.
  4. Immediately after ready, the host emits authorization:changed — always, even when the value is null. Riftix.ready does not resolve until this first authorization event is received.

Message envelope

Every message is a plain JSON object with a __riftix_bridge tag that distinguishes bridge messages from all other postMessage traffic in the page:

{ __riftix_bridge: "__riftix_bridge", kind: "hello" | "ready" | "req" | "res" | "event", ... }

See Protocol Reference for the full envelope schema.


RPC flow

Client                               Host
  │                                    │
  │──── req { id, method, payload } ──▶│
  │                                    │  runs registered handler
  │◀─── res { id, ok, data/error } ───│
  • Each request has a unique id (from crypto.randomUUID()).
  • The client waits for a res envelope with a matching id. Default timeout: 10s (DEFAULT_RPC_TIMEOUT_MS).
  • If no handler is registered, the host responds with METHOD_NOT_FOUND.
  • Errors in handlers are caught and returned as ok: false with code HANDLER_ERROR.

Events (host → client, push only)

Events flow only from host to client. The client never pushes events — it uses RPCs.

EventTrigger
context:changedHost calls host.broadcastContext(next) or channel.setContext(next)
authorization:changedHost calls host.setAuthorizationForProduct(productId, next) or channel.setAuthorization(next)
navigation:navigateHost calls channel.emit("navigation:navigate", { path, replace? }) to drive the iframe to a path

Both state events carry a full snapshot — the entire HostContext or BridgeAuthorization | null — not a diff. The client replaces its local copy on every event.


URL / browser history sync

The bridge provides bidirectional URL synchronisation between the iframe and the host shell:

Client → Host (URL mirroring)

Iframe URL changes (pushState / replaceState / popstate)

  │  snippet intercepts via monkey-patched history API

  │──── navigation.locationChanged { path: "/invoices/123" } ──▶ host

  │  host (ConsoleIframe's onLocation callback) calls:
  │  window.history.replaceState(null, "", "/billing/invoices/123")

  │  Host URL bar now shows /billing/invoices/123
  │  (No iframe reload — replaceState only updates the URL bar)

The path is iframe-relative (/invoices/123). The host prepends its own product route prefix (/billing) when updating the URL bar.

When the user opens console.example.com/billing/invoices/123 directly:

  1. TanStack Router in the host matches /billing/$ with _splat = "invoices/123".
  2. ConsoleIframe receives src="http://billing.example.com/invoices/123" — the sub-path is baked into the iframe src.
  3. The client loads at /invoices/123 from the start — no postMessage needed.

Host → Client (programmatic navigation after load)

When the host needs to navigate a running iframe (e.g. host-level back/forward):

host calls: channel.emit("navigation:navigate", { path: "/invoices/456", replace: true })

  │  snippet receives navigation:navigate event
  │  calls: history.replaceState(null, "", "/invoices/456")
  │  dispatches: new PopStateEvent("popstate")   ← triggers in-page router

  │  echo-loop guard: suppressNextReport = true prevents the
  │  resulting URL change from being echoed back to the host

Echo-loop prevention

Without a guard, each party's URL update would trigger the other indefinitely:

  • Iframe navigates → host URL updates → host sends navigation:navigate → iframe navigates → ...

The guard: when the snippet handles a navigation:navigate event, it sets suppressNextReport = true before calling history.replaceState/pushState. The monkey-patch checks this flag and skips the navigation.locationChanged RPC for that one call.


Security

Origin allowlist

Each IframeChannel is constructed with an explicit origin string. The host drops any message whose event.origin does not match. '*' is never accepted on the host side; the first hello is sent with '*' from the client only because the host origin is unknown until the snippet reads <meta name="riftix-host">.

<meta name="riftix-host">

If the meta tag is present its content value is used as expectedHostOrigin for the ConsoleClient. The client then:

  • Sends hello to that specific origin (not '*').
  • Drops any ready that does not originate from that origin.

If the tag is absent, the client accepts the first ready from any origin and locks onto it.

Idempotent bootstrap

The snippet is idempotent: if window.Riftix already exists, the bootstrap returns immediately. If the page is not inside an iframe (window.parent === window), the bootstrap is a no-op.


The client owns the full breadcrumb trail. When window.Riftix.navigation.setBreadcrumbs({ crumbs }) is called (or fired by the web components), the crumbs array replaces the host's breadcrumb state for that product entirely. The host never prepends its own "Riftix Console" root node when the client has pushed crumbs.

When the client disconnects (iframe unloaded, <riftix-page> removed from DOM), the breadcrumb state is cleared by sending { crumbs: [] }.


StrictMode safety

The host React provider creates its ConsoleHost instance inside useEffect (not useRef or lazy state) so that React's double-invocation in StrictMode disposes and recreates the instance cleanly. The provider renders null until the effect has run, ensuring children never see a disposed host.

On this page