Skip to main content
This document explains the architecture and design principles behind the GBGBridge Web SDK. Understanding these concepts will help you integrate effectively and make informed decisions about how to structure your host page.

Overview

GBGBridge solves a fundamental problem: web-based identity verification journeys, when embedded as an iframe inside another web page, are subject to browser-level origin isolation that prevents the journey from accessing host-page state, calling host functions, or triggering host UI directly. Rather than building separate host-page logic for every journey variation, GBGBridge provides a structured postMessage protocol so a single journey can run anywhere — inside an iOS WebView, an Android WebView, or an iframe on a web host — and request host capabilities through the same interface. The Web Bridge SDK is the host-page half of that protocol. The other half — the iframe-side SDK that runs inside the journey iframe — is documented separately under the TypeScript Bridge SDK.

Key Terminology

Architecture

The Web SDK has two layered packages: a framework-agnostic core (@gbg/go-bridge-web) and a React adapter (@gbg/go-bridge-web-react) that wraps the core in a Provider and hooks.

Component Diagram

BridgeHostProvider is a thin React adapter that owns the BridgeHost lifecycle. Non-React hosts construct new BridgeHost(...) directly. The transport details live in HostIframeChannel, which the host owns internally.

Layers

  1. Adapter LayerBridgeHostProvider (React) holds the underlying BridgeHost in component state, snapshots config props on attach, and tears down on unmount. Non-React hosts skip this layer.
  2. Routing LayerBridgeHost receives validated messages from the channel, appends them to observable buffers, fans out to the delegate, and routes request-type messages to registered handlers.
  3. Handler LayerBridgeCapabilityHandler is an interface with a single handle(request, responder) method. The built-in CapabilityQueryHandler services capability.query automatically; integrators register additional handlers via useBridgeCapability(...) (React) or host.registerHandler(...) (direct).
  4. Transport LayerHostIframeChannel owns the single window message listener, performs origin and envelope validation, and uses iframe.contentWindow.postMessage for outbound sends. Origin checks are enforced on both inbound and outbound traffic.
  5. Observation LayerBridgeHostDelegate exposes four optional callbacks — onMessage, onUnhandledRequest, onMessageSent, onError — for full visibility into the message flow.

Message Protocol

All communication uses structured JSON envelopes. The protocol version is 1.0, and the wire format is identical to the iOS and Android SDKs — a web journey that speaks the bridge protocol works unchanged on all three platforms.

Message Envelope

timestamp is epoch milliseconds. payload.action is required on all three message types — envelopes without a non-empty string action are rejected at the transport layer. The wire protocol version is exposed as BridgeHost.PROTOCOL_VERSION for telemetry. On version mismatch, the host logs a warning and continues processing rather than failing — this lets minor protocol additions roll out without breaking older integrations.

Message Types

Message Flow

Correlation IDs

Every request has a correlationId. When the host responds, it includes the same correlationId so the journey can match the response to its original request. Host-emitted events generate their own unique correlation IDs in the format web-event-{uuid} (the SDK uses crypto.randomUUID() where available, with a timestamp+random fallback for older environments).

Response Statuses

BridgeResponder enforces at-most-once delivery on terminal statuses. After a terminal status is sent, subsequent calls on the same responder are silently no-ops. acknowledge() is the one exception: it may be called once before any terminal status, and the responder remains usable for the terminal call.

Error Codes

The protocol defines a small set of well-known error codes, exposed as the BridgeErrorCode enum: Handlers that throw an uncaught exception trigger an automatic error response with code HANDLER_FAILURE and recoverable: false, so the journey is never left waiting on a request that the host silently dropped. The error is also routed to delegate.onError.

Request Routing

When a message arrives from the journey iframe:
  1. The HostIframeChannel’s window message listener fires.
  2. Source check — Events whose event.source is not the iframe’s contentWindow are dropped silently. This prevents cross-window leakage from other iframes on the page.
  3. Origin check — Events whose event.origin is not in allowedOrigins (after normalization) are dropped silently.
  4. Envelope validation — Events with malformed envelopes (missing version, correlationId, type, timestamp, payload.action, or invalid type) are dropped silently.
  5. The validated BridgeMessage is handed to BridgeHost.handleIncomingMessage.
  6. The message is appended to receivedMessages (an observable buffer capped at the most recent 200 messages).
  7. delegate.onMessage(host, message) fires.
  8. If message.type === 'request':
    • If a handler is registered for payload.action, handler.handle(message, responder) is called.
    • If no handler is registered, the message is pushed to pendingRequests (capped at 50; oldest dropped on overflow) and delegate.onUnhandledRequest(host, message) fires. You can respond later via host.respond({ correlationId, status, ... }).
  9. If message.type === 'response' or 'event', no routing occurs — the message is just observed.
Handler exceptions are caught: the error is routed to delegate.onError, and if the handler had not yet responded, an error response with code HANDLER_FAILURE is dispatched automatically so the journey is never left waiting.

Declaring vs. Registering Capabilities

A platform divergence worth understanding upfront: on iOS and Android, the Bridge SDK exposes typed capability slots — first-class properties on BridgeHost (e.g. host.documentCapture) where setting a handler simultaneously declares the capability as supported. The web SDK does not have typed slots. Instead, declaration and registration are two separate calls: In the typical React pattern:
If you only register the handler (with no capabilities entry), the journey’s capability.query response will not list camera.document as supported. The journey will fall back to its alternative flow even though your handler is sitting ready. This is the most common integration mistake — there is a dedicated entry in Getting Started → Common Pitfalls. For dynamic capability additions at runtime (e.g. after a permission grant), use host.registerCustomCapability(id, version, handler) — it declares and registers in one call.

Capability ID vs. Action ID

The protocol uses two related strings:
  • Capability ID (e.g. camera.document) — names the feature. Appears in the capabilities map and capability.query responses. Listed as constants in CAPABILITY_IDS.
  • Action ID (e.g. camera.document.capture) — names the specific operation. Appears in useBridgeCapability(...), registerHandler(...), and inbound payload.action.
Action IDs typically contain the capability ID plus a verb suffix. The journey sends a request with payload.action = "camera.document.capture"; the host has previously registered a handler for that action and declared support for the underlying camera.document capability.

Capability Negotiation

Before invoking a capability, the journey sends a capability.query request. The host’s built-in CapabilityQueryHandler responds automatically with the effective capability map — your declared capabilities plus any added via registerCustomCapability.

Query Response Format

supported and version are always emitted (version is null when unset). permissionState is included only when you set it in the CapabilityInfo entry. The environment field is the literal string "web" for this SDK; iOS reports "ios", Android reports "android".

Why declare?

The journey uses this response to:
  • Adapt its UI — show or hide steps based on what the host can fulfil.
  • Prevent errors — skip requests for unsupported capabilities and fall straight to alternatives.
  • Check permissions — surface permission state before triggering a request, avoiding immediate denial.
  • Degrade gracefully — use an in-iframe fallback (e.g. getUserMedia in the iframe itself) when the host doesn’t expose a capability.

Transport Mechanism

The Web SDK uses the browser’s native postMessage channel in both directions, with origin validation enforced on both sides.

Iframe → Host (Incoming)

The journey iframe sends:
The host page’s window message listener receives the event. HostIframeChannel filters it: event.source must equal the host’s iframe.contentWindow, event.origin must be in allowedOrigins, and the envelope must validate. Surviving events are decoded and routed to BridgeHost. There is no script injection in the iframe — the host page does not inject any JavaScript into the journey iframe. Cross-origin restrictions would prevent it anyway. The journey loads its own iframe-side SDK independently as part of its bundle.

Host → Iframe (Outgoing)

The host sends:
HostIframeChannel resolves targetOrigin from iframe.src at send time (so a late iframe.src assignment is picked up) and validates it against allowedOrigins — outbound messages to a non-allowlisted origin are dropped with a console.warn. This guards against a repointed iframe.src leaking payloads to a non-trusted host.

Why origin validation on both sides?

Same-origin policy doesn’t prevent a page from posting messages to an iframe whose contentWindow it holds a reference to — it only blocks direct DOM access. Without explicit origin filtering, a malicious script on the host page could redirect the iframe to a different origin (via iframe.src = ...) and start receiving the host’s outbound messages. Two-sided origin validation closes that hole.

Comparison with iOS and Android

React Lifecycle

BridgeHostProvider ties the underlying BridgeHost instance to React component state. Three rules govern this binding:
  1. Build on iframe attach. The provider holds off until iframe is non-null. When the iframe element appears (via the ref={setIframe} callback), the provider instantiates BridgeHost and stores it in state, which then propagates through BridgeHostContext to child hooks.
  2. Config snapshot at attach time. allowedOrigins, hostVersion, and capabilities are read into a snapshot when the host is built. Subsequent renders that change these props do not rebuild the host. To apply changes, force a Provider remount (key={...} rotation) or call methods on the host instance directly (e.g. registerCustomCapability).
  3. Detach on unmount. When the provider unmounts (or the iframe transitions back to null), the effect cleanup calls host.detach() and clears the host from state. The internal window message listener is removed at this point, so there is no leak.
For non-React hosts using new BridgeHost(...) directly, you own the lifecycle. Call host.detach() when the iframe is no longer in use to remove the message listener and free any pending request buffers.

Threading model

JavaScript is single-threaded — there is no main-thread / background-thread distinction to manage. Handlers may be async and the responder’s terminal methods can be called from any continuation. The protocol’s at-most-once enforcement is purely state-based, not thread-based.

Platform divergences from iOS / Android

The Web SDK speaks the same wire protocol as iOS and Android, but a few implementation details differ because the browser model isn’t a native WebView. The table lists the divergences integrators porting host code from iOS or Android are most likely to hit. The full parity inventory (PARITY.md) lives in the internal SDK source repo.

Design Rationale

The design choices below aren’t obvious from the API surface alone. Each Q&A explains why a specific decision was taken, and what it costs or buys the integrator.

Why a message protocol instead of direct method calls?

A message protocol decouples the journey from the host. The journey doesn’t need to know whether it’s running inside an iOS WebView, an Android WebView, or an iframe in a web page — it sends the same messages regardless. This also allows the protocol to evolve without breaking existing integrations.

Why declare capabilities upfront?

Capability declaration serves two purposes: it lets the journey adapt before hitting a dead end, and it gives the host explicit control over what features are exposed. A host page might run on a device with a camera but choose not to expose camera capture for a particular journey — for example, because the host’s UX provides a richer capture flow than what the iframe can offer.

Why separate declare and register on web (vs. typed slots on iOS / Android)?

iOS and Android can expose typed properties on a strongly-typed BridgeHost because their languages support it ergonomically (Swift’s @StateObject, Kotlin’s properties with backing fields). In a React + TypeScript host, the equivalent would require either generating a typed Provider for each capability or using non-idiomatic prop shapes. The capabilities prop + useBridgeCapability split keeps the React API conventional — declarative props for state, hooks for side effects — at the cost of one extra integration step. registerCustomCapability(id, version, handler) is available on the underlying host for callers who prefer the typed-slot ergonomics.

Why an origin allowlist?

Unlike a native WebView (where the host has full control over what URL loads), an iframe can be repointed by any script in the host page. The allowedOrigins allowlist makes this safe: an attacker who repoints iframe.src to a hostile origin cannot send messages to or receive messages from the host’s bridge. Origin validation is enforced on both inbound and outbound traffic for this reason.

Why no bootstrap script injection?

iOS and Android inject a script into the native WebView at document start to expose window.GBGBridge. On web, the iframe is cross-origin — the host page cannot execute JavaScript inside it. The journey iframe loads its own SDK as part of its bundle. This is a hard browser constraint, not a design choice — but it also keeps the host-side surface simpler: there is no bootstrap configuration to manage.

Why does the React Provider snapshot config?

BridgeHost instances are not free to rebuild — the message listener wiring, capability registration, and pending-request state would all reset. Snapshotting allowedOrigins, hostVersion, and capabilities at attach time means a parent re-render that changes those props (often unintentionally — e.g. a new array literal) does not silently rebuild the host. Integrators who genuinely need to apply runtime config changes use either key-based remount or the registerCustomCapability API.

Why zero runtime dependencies?

GBGBridge sits on a hot path: every iframe message routes through it. Adding dependencies would increase bundle size, create version conflicts with consumer apps, and introduce supply-chain risk. The core uses only standard browser APIs (postMessage, MessageEvent, URL, crypto.randomUUID with fallback). The React package depends only on React itself, as a peer dependency.

Next Steps

  • API Reference — Detailed documentation for every public symbol.
  • Embedding — React, framework-agnostic, and SSR-aware integration patterns.
  • Messaging — Sending events and handling requests in depth.
  • Capability Handling — Declaring, registering, and dynamic capability negotiation.