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 │- The client sends
helloevery 100ms (up to 10s) until it receivesready. - The host validates
event.source === iframe.contentWindowandevent.origin === registeredOrigin. Unknown origins are silently dropped. readycarries the initialHostContextsnapshot so the client has theme/locale before its first paint.- Immediately after
ready, the host emitsauthorization:changed— always, even when the value isnull.Riftix.readydoes 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(fromcrypto.randomUUID()). - The client waits for a
resenvelope with a matchingid. 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: falsewith codeHANDLER_ERROR.
Events (host → client, push only)
Events flow only from host to client. The client never pushes events — it uses RPCs.
| Event | Trigger |
|---|---|
context:changed | Host calls host.broadcastContext(next) or channel.setContext(next) |
authorization:changed | Host calls host.setAuthorizationForProduct(productId, next) or channel.setAuthorization(next) |
navigation:navigate | Host 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.
Host → Client (deep link on load)
When the user opens console.example.com/billing/invoices/123 directly:
- TanStack Router in the host matches
/billing/$with_splat = "invoices/123". ConsoleIframereceivessrc="http://billing.example.com/invoices/123"— the sub-path is baked into the iframe src.- The client loads at
/invoices/123from 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 hostEcho-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
helloto that specific origin (not'*'). - Drops any
readythat 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.
Breadcrumbs design
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.