Skip to content

david-pro-max/bridge-protocol

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@pro-max-max/bridge-protocol

Capability-based postMessage protocol for CesiumJS host ↔ iframe guest communication.

A TypeScript-only package that defines the complete contract between a parent application hosting a CesiumJS globe and a sandboxed <iframe> guest application that needs to control it. Zero runtime dependencies.


Table of contents

  1. Overview
  2. Architecture
  3. Installation
  4. Handshake flow
  5. Capability model
  6. Capability reference
  7. Message envelope reference
  8. Shared payload types
  9. Error codes
  10. Security model
  11. Event subscriptions
  12. Versioning & compatibility
  13. Extending the protocol
  14. Changelog

1. Overview

When you embed a third-party (or internally-built) application inside a <iframe>, you face two problems:

  1. Authentication — the guest needs to know who the user is and carry a valid token.
  2. Map control — the guest wants to drive the parent's CesiumJS globe (fly camera, add pins, draw shapes, subscribe to click events, …) but the host must be able to restrict exactly which APIs are accessible.

This package solves both by providing:

  • A shared message envelope (channel, kind, correlation id) that both sides understand.
  • A typed capability catalog — every Cesium operation is a named string (e.g. cesium.camera.flyTo) with typed payload and result defined in TypeScript.
  • A permission model — the host sends an allowlist; anything not in the list is denied at the bridge layer before Cesium is ever touched.
  • An event model — the guest can subscribe to globe events (clicks, camera moves, draw completion) and receive a typed push stream from the host.

This package is protocol-only — it ships no DOM, no Cesium dependency, no React. The actual bridge implementations (BridgeHost, BridgeClient) live in the host and guest apps respectively, and reference this package as their single source of truth.


2. Architecture

┌──────────────────────────────────────────────────────────────────────┐
│  HOST (parent window)                                                │
│                                                                      │
│  ┌────────────┐   policy    ┌─────────────┐   imperative   ┌──────┐ │
│  │  PolicyUI  │────────────▶│  BridgeHost │───────────────▶│Cesium│ │
│  └────────────┘             └──────┬──────┘                └──────┘ │
│                                    │ postMessage(origin-checked)     │
└────────────────────────────────────┼─────────────────────────────────┘
                                     │  window
                                     │
┌────────────────────────────────────┼─────────────────────────────────┐
│  GUEST (<iframe sandbox>)          │                                 │
│                                    ▼                                 │
│  ┌──────────────┐   useBridge  ┌──────────────┐                     │
│  │  GuestUI     │◀─────────────│  BridgeClient│                     │
│  │  (buttons,   │              │  .request()  │                     │
│  │   event log) │              │  .subscribe()│                     │
│  └──────────────┘              └──────────────┘                     │
└──────────────────────────────────────────────────────────────────────┘

          Both sides share: @pro-max-max/bridge-protocol
          ──────────────────────────────────────
          CESIUM_CAPABILITIES, CapabilityIO, BridgeMessage, …

Key principle: The host never exposes the Cesium Viewer object to the guest. Instead, every operation is an explicit named capability with a validated payload and a typed result. The guest can only call what the host has registered and allowed in its current policy.


3. Installation

npm install @pro-max-max/bridge-protocol
# or
yarn add @pro-max-max/bridge-protocol
# or
pnpm add @pro-max-max/bridge-protocol

Both ESM and CommonJS builds are included. Full TypeScript declarations (.d.ts) are included — no @types package needed.

Node requirement: ES2020 target. Works in any modern bundler (Vite, webpack 5, Rollup, esbuild, …).


4. Handshake flow

The protocol uses a 3-step handshake before any capability call is possible.

HOST                                    GUEST
 │                                        │
 │  1. <iframe src=GUEST_URL> mounted     │
 │                              (JS runs) │
 │                                        │  2. BridgeClient.start()
 │                ◀── 'ready' ──────────  │     postMessage { kind:'ready', protocolVersion }
 │
 │  3. onChildReady()
 │     send 'hello' ──────────────────▶  │  4. onMessage('hello')
 │     {                                 │     session   = msg.session
 │       protocolVersion,                │     capabs    = msg.capabilities
 │       session: {                      │     → UI renders with correct locks/unlocks
 │         accessToken,                  │
 │         user,                         │
 │         authServerUrl,                │
 │       },                              │
 │       capabilities: string[]          │
 │     }                                 │
 │                                       │
 │  ── ongoing ──                        │
 │
 │  'auth-update' ────────────────────▶  │  new token after refresh
 │  'policy-update' ──────────────────▶  │  host admin changed permissions
 │  'event' ──────────────────────────▶  │  globe click / camera move / draw done
 │                                       │
 │                ◀── 'request' ──────── │  guest invokes a capability
 │  'response' ───────────────────────▶  │  result or error (correlated by id)

Important: The host never initiates sending hello unprompted. It waits for ready from the guest. This prevents race conditions where the guest has not yet registered its message listener.


5. Capability model

What is a capability?

A capability is a named string of the form namespace.noun.verb, e.g.:

cesium.camera.flyTo
cesium.pins.add
cesium.events.globeClick.subscribe

Each capability has:

Field Type Description
name CesiumCapability The unique string ID
title string Human-readable label (for Policy UI)
description string What it does
group 'camera' | 'pins' | ... UI grouping namespace
safe boolean true = safe to grant by default; false = host should opt-in explicitly
isSubscription? boolean This capability starts an event stream

How the policy works

The host maintains a Set of granted capabilities. When the guest calls request(capability, payload):

  1. Host checks: is capability in the policy set? → if no: respond CAPABILITY_DENIED.
  2. Host checks: is there a registered handler? → if no: respond HANDLER_MISSING.
  3. Host calls the handler → on throw: respond HANDLER_FAILED or INVALID_PAYLOAD.
  4. Host responds with { ok: true, result }.

The guest receives the response as the resolved value of the Promise returned by client.request(...). A denied or failed request rejects the promise with a BridgeRequestError.

Default policy guidance

Status Capabilities
✅ Safe default All camera navigation, add/remove pins, add/remove polygons/polylines/circles, picking, viewport query, event subscriptions
⚠️ Host opt-in camera.getState, camera.setView, scene.setTime, all .clear (bulk destructive)
🔴 Sensitive layers.tiles3d.load, layers.imagery.setBase (network/supply-chain risk)
🔇 Host only drawing.* (requires host UX)

6. Capability reference

All 37 capabilities organized by group. Each entry shows the TypeScript payload and result from CapabilityIO.

cesium.camera.*

Capability Payload Result Safe
cesium.camera.flyTo FlyToPayload { ok: true }
cesium.camera.zoomIn ZoomPayload { ok: true }
cesium.camera.zoomOut ZoomPayload { ok: true }
cesium.camera.reset {} { ok: true }
cesium.camera.setView SetViewPayload { ok: true } ⚠️
cesium.camera.getState {} CameraState ⚠️

cesium.pins.*

Capability Payload Result Safe
cesium.pins.add AddPinPayload { ok: true, id: string }
cesium.pins.remove { id: string } { ok: true, removed: boolean }
cesium.pins.clear {} { ok: true, cleared: number } ⚠️

cesium.polygons.*

Capability Payload Result Safe
cesium.polygons.add AddPolygonPayload { ok: true, id: string }
cesium.polygons.remove { id: string } { ok: true, removed: boolean }
cesium.polygons.clear {} { ok: true, cleared: number } ⚠️

cesium.polylines.*

Capability Payload Result Safe
cesium.polylines.add AddPolylinePayload { ok: true, id: string }
cesium.polylines.remove { id: string } { ok: true, removed: boolean }
cesium.polylines.clear {} { ok: true, cleared: number } ⚠️

cesium.circles.*

Capability Payload Result Safe
cesium.circles.add AddCirclePayload { ok: true, id: string }
cesium.circles.remove { id: string } { ok: true, removed: boolean }
cesium.circles.clear {} { ok: true, cleared: number } ⚠️
cesium.circles.updateRadius UpdateCircleRadiusPayload { ok: true }

cesium.picking.*

Capability Payload Result Safe
cesium.picking.pickCoordinate PickAtScreenPayload PickCoordinateResult | null
cesium.picking.pickEntity PickAtScreenPayload PickEntityResult

cesium.scene.*

Capability Payload Result Safe
cesium.scene.requestRender {} { ok: true }
cesium.scene.setTime SetTimePayload { ok: true, iso: string } ⚠️
cesium.scene.getViewportBounds {} ViewportBounds

cesium.layers.*

Capability Payload Result Safe
cesium.layers.tiles3d.load LoadTilesetPayload { ok: true, id: string } 🔴
cesium.layers.tiles3d.unload { id: string } { ok: true, removed: boolean } 🔴
cesium.layers.imagery.setBase SetImageryPayload { ok: true } 🔴

cesium.drawing.*

Interactive drawing — host must wire its own UX (RectangleDrawer, PolygonDrawer, RadiusPicker) to these capabilities. Results are delivered via cesium.events.drawComplete event, not the capability result.

Capability Payload Result Safe
cesium.drawing.startRectangle StartRectangleDrawPayload { ok: true }
cesium.drawing.startPolygon StartPolygonDrawPayload { ok: true }
cesium.drawing.startRadius StartRadiusDrawPayload { ok: true }
cesium.drawing.cancel {} { ok: true }

cesium.events.* (subscriptions)

Capability Effect
cesium.events.globeClick.subscribe Start receiving cesium.events.globeClick event messages
cesium.events.globeClick.unsubscribe Stop receiving globe-click events
cesium.events.cameraMoveEnd.subscribe Start receiving camera move-end events
cesium.events.cameraMoveEnd.unsubscribe Stop
cesium.events.drawComplete.subscribe Start receiving draw-complete events
cesium.events.drawComplete.unsubscribe Stop

7. Message envelope reference

All messages share the discriminated union shape:

{
  channel: 'poc.bridge',   // constant — used to filter out unrelated postMessages
  kind: string,            // discriminant
  ...fields
}

kind: 'ready' — Guest → Host

Sent immediately when the guest's BridgeClient.start() runs.

{
  channel: 'poc.bridge',
  kind: 'ready',
  protocolVersion: string,
}

kind: 'hello' — Host → Guest

Sent in response to ready. Carries the user session and initial policy.

{
  channel: 'poc.bridge',
  kind: 'hello',
  protocolVersion: string,
  session: {
    accessToken: string,
    user: { id: string; email: string; name: string },
    authServerUrl: string,
  },
  capabilities: CesiumCapability[],
}

kind: 'auth-update' — Host → Guest

Sent when the host refreshes the access token (user stays logged in, token rotates).

{ channel: 'poc.bridge', kind: 'auth-update', accessToken: string }

kind: 'policy-update' — Host → Guest

Sent whenever the host admin changes the capability policy at runtime.

{ channel: 'poc.bridge', kind: 'policy-update', capabilities: CesiumCapability[] }

kind: 'request' — Guest → Host

{
  channel: 'poc.bridge',
  kind: 'request',
  id: string,                  // correlation ID (unique per request)
  capability: CesiumCapability,
  payload: CapabilityPayload<typeof capability>,
}

kind: 'response' — Host → Guest

{
  channel: 'poc.bridge',
  kind: 'response',
  id: string,                  // mirrors the request id
  capability: CesiumCapability,
  ok: boolean,
  result?: CapabilityResult<typeof capability>,
  error?: { code: BridgeErrorCode; message: string },
}

kind: 'event' — Host → Guest

Only sent for topics the guest has an active subscription for.

{
  channel: 'poc.bridge',
  kind: 'event',
  topic: EventTopic,
  payload: EventPayload<typeof topic>,
}

8. Shared payload types

// Geographic primitives
interface LatLng      { latitude: number; longitude: number }
type    LngLatAlt     = [lng: number, lat: number, height?: number]

interface ViewportBounds { minLat: number; maxLat: number; minLng: number; maxLng: number }

interface CameraState extends LatLng {
  height:   number
  heading?: number   // radians
  pitch?:   number   // radians
  roll?:    number   // radians
}

// Camera
interface FlyToPayload extends LatLng {
  height?:   number   // alt above ellipsoid, meters
  distance?: number   // camera distance from target, meters (default 2000)
  duration?: number   // animation seconds (default 2)
  heading?:  number   // radians
  pitch?:    number   // radians
}

interface SetViewPayload extends LatLng {
  height:   number
  heading?: number; pitch?: number; roll?: number
}

interface ZoomPayload { amount?: number }  // meters

// Entities
interface AddPinPayload extends LatLng {
  id:        string
  height?:   number
  color?:    string   // CSS color, e.g. '#E53E3E'
  label?:    string
  metadata?: Record<string, unknown>
}

interface AddPolygonPayload {
  id:              string
  positions:       LngLatAlt[]   // outer ring, ≥ 3 points + close
  holes?:          LngLatAlt[][] // each hole is a closed ring
  height?:         number
  extrudedHeight?: number        // makes a 3D box
  color?:          string
  outlineColor?:   string
  outlineWidth?:   number
  name?:           string
  metadata?:       Record<string, unknown>
}

interface AddPolylinePayload {
  id:             string
  positions:      LngLatAlt[]   // ≥ 2 points
  color?:         string
  width?:         number        // pixels (default 2)
  clampToGround?: boolean       // default true
  metadata?:      Record<string, unknown>
}

interface AddCirclePayload extends LatLng {
  id:            string
  radiusMeters:  number
  color?:        string
  outlineColor?: string
  outlineWidth?: number
  metadata?:     Record<string, unknown>
}

interface UpdateCircleRadiusPayload { id: string; radiusMeters: number }

// Picking
interface PickAtScreenPayload    { screenX: number; screenY: number }
interface PickCoordinateResult   extends LatLng { height?: number }
interface PickEntityResult       { entityId: string | null; metadata?: Record<string, unknown> }

// Scene
interface SetTimePayload         { iso: string }  // ISO-8601

// Layers
interface LoadTilesetPayload     { id: string; url: string; ionAssetId?: number }
interface SetImageryPayload      { urlTemplate: string; attribution?: string }

// Drawing
interface StartRectangleDrawPayload { color?: string }
interface StartPolygonDrawPayload   { color?: string; minPoints?: number }
interface StartRadiusDrawPayload    { radiusMeters?: number; color?: string }

// Events
interface GlobeClickEvent extends LatLng {
  height?:        number
  screenX?:       number
  screenY?:       number
  entityId?:      string
  entityMetadata?: Record<string, unknown>
}

interface CameraMoveEndEvent { state: CameraState; bounds: ViewportBounds }

interface DrawCompleteEvent {
  kind:       'rectangle' | 'polygon' | 'radius'
  rectangle?: { ne: LatLng; sw: LatLng }
  polygon?:   { positions: LatLng[] }
  radius?:    { center: LatLng; radiusMeters: number }
}

9. Error codes

const BridgeErrorCode = {
  UNKNOWN_CAPABILITY: 'UNKNOWN_CAPABILITY',  // capability string not in the catalog
  CAPABILITY_DENIED:  'CAPABILITY_DENIED',   // not in the host's current policy
  HANDLER_MISSING:    'HANDLER_MISSING',     // declared but no handler registered
  INVALID_PAYLOAD:    'INVALID_PAYLOAD',     // payload failed schema validation
  HANDLER_FAILED:     'HANDLER_FAILED',      // handler threw an unexpected error
  NOT_IMPLEMENTED:    'NOT_IMPLEMENTED',     // intentionally unimplemented
  TIMEOUT:            'TIMEOUT',             // client-side timeout (default 5 s)
}

Every non-OK response includes:

{ code: BridgeErrorCode; message: string }

The guest-side BridgeRequestError (from the implementation package) wraps this:

class BridgeRequestError extends Error {
  code:       BridgeErrorCode
  capability: CesiumCapability
}

10. Security model

Origin enforcement (critical)

Both sides must validate event.origin against the known peer origin before processing any message. Reject silently if origins don't match. The BRIDGE_CHANNEL constant provides a secondary filter, but origin checking is the real security boundary.

// Host side
window.addEventListener('message', (event) => {
  if (event.origin !== GUEST_ORIGIN) return;   // ← hard reject
  if (!isBridgeMessage(event.data))  return;
  // …
});

// Guest side — same pattern with PARENT_ORIGIN

What the protocol does NOT protect against

  • A malicious host page (phishing/framebusting) — the guest should verify the parentOrigin it was configured with before accepting a hello.
  • Payload data integrity — the host's handler is responsible for validating payload values (e.g. bounding coordinates to a geographic region).
  • Rate limiting — a guest can spam request messages; the host should add request throttling for production use.

Capability allowlist philosophy

The policy is an allowlist, not a denylist. A guest can only call capabilities that are:

  1. Declared in CESIUM_CAPABILITIES (part of the contract).
  2. Registered with a handler by the host (host.register(cap, handler)).
  3. Present in the host's current policy set (host.setPolicy([...])).

All three must hold simultaneously.

sandbox attribute

The <iframe> element should use:

<iframe
  src="..."
  sandbox="allow-scripts allow-same-origin allow-forms"
/>

allow-scripts — guest JS runs.
allow-same-origin — required for postMessage to carry the correct origin.
allow-forms — for auth forms in the guest.

Do not add allow-top-navigation or allow-popups unless your threat model explicitly requires them.


11. Event subscriptions

Events flow host → guest only, and only after the guest sends the corresponding *.subscribe capability request.

Subscription lifecycle

Guest                                  Host
  │                                      │
  │  request('cesium.events.globeClick   │
  │             .subscribe', {})  ──────▶│  addSubscription('cesium.events.globeClick')
  │                                      │
  │◀── response { ok: true } ────────────│
  │                                      │
  │◀── event('cesium.events.globeClick') │  (on each user click)
  │◀── event(...)                        │
  │                                      │
  │  request('...globeClick              │
  │             .unsubscribe', {}) ─────▶│  removeSubscription(...)
  │                                      │

Policy revocation mid-subscription

If the host admin removes cesium.events.globeClick.subscribe from the policy while the guest has an active subscription:

  1. The host's setPolicy() immediately removes the topic from its internal subscription set.
  2. No further event messages are sent for that topic.
  3. The guest is notified via policy-update (it loses the subscribe capability from its set).
  4. A well-behaved guest implementation should detect the lost capability and mark the subscription as inactive in its UI.

Available event topics

Topic Payload type Triggered when
cesium.events.globeClick GlobeClickEvent User left-clicks the globe
cesium.events.cameraMoveEnd CameraMoveEndEvent Camera finishes animating/panning
cesium.events.drawComplete DrawCompleteEvent Interactive drawing mode confirms a shape

12. Versioning & compatibility

PROTOCOL_VERSION follows semver (MAJOR.MINOR.PATCH):

Change Version bump
New capability added, existing unchanged MINOR
Payload field added (optional) MINOR
Payload field removed or type changed MAJOR
Error code added MINOR
Envelope kind or channel changed MAJOR
Capability name renamed MAJOR

Compatibility check (recommended)

The guest should verify on hello:

import { PROTOCOL_VERSION } from '@pro-max-max/bridge-protocol';

const [myMajor] = PROTOCOL_VERSION.split('.');
const [hostMajor] = msg.protocolVersion.split('.');
if (myMajor !== hostMajor) {
  throw new Error(`Protocol version mismatch: host=${msg.protocolVersion} guest=${PROTOCOL_VERSION}`);
}

MINOR differences are safe to ignore (the older side simply won't know about the newer capabilities).


13. Extending the protocol

Adding a new capability (typical workflow)

  1. Protocol package (@pro-max-max/bridge-protocol) — edit index.ts:

    • Add the string to CESIUM_CAPABILITIES.
    • Add its CapabilityDescriptor to CAPABILITY_CATALOG.
    • Add its payload / result types to CapabilityIO.
    • Bump PROTOCOL_VERSION (MINOR).
    • Run npm run build.
  2. Host app — in cesiumCapabilities.ts (or equivalent):

    host.register('cesium.my.newThing', async (payload) => {
      // validate payload
      // call viewer.doSomething(...)
      return { ok: true };
    });

    Add 'cesium.my.newThing' to DEFAULT_POLICY if safe.

  3. Guest app — add a button / UI that calls:

    const result = await client.request('cesium.my.newThing', { ...payload });

No changes required to the bridge infrastructure (BridgeHost / BridgeClient). The capability name is the only coupling point between host and guest.

Adding a new event topic

  1. Add payload type to EventTopicIO in index.ts.
  2. Add the topic string to EVENT_TOPICS.
  3. Add subscribe + unsubscribe capability entries everywhere.
  4. In the host's BridgeHost.ts: add the topic → subscribe-capability mapping in TOPIC_TO_SUBSCRIBE_CAP.
  5. In the host implementation: call host.emit(topic, payload) when the Cesium event fires.

14. Changelog

See CHANGELOG.md.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors