Capability-based
postMessageprotocol 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.
- Overview
- Architecture
- Installation
- Handshake flow
- Capability model
- Capability reference
- Message envelope reference
- Shared payload types
- Error codes
- Security model
- Event subscriptions
- Versioning & compatibility
- Extending the protocol
- Changelog
When you embed a third-party (or internally-built) application inside a <iframe>, you face two problems:
- Authentication — the guest needs to know who the user is and carry a valid token.
- 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, correlationid) that both sides understand. - A typed capability catalog — every Cesium operation is a named string (e.g.
cesium.camera.flyTo) with typedpayloadandresultdefined 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.
┌──────────────────────────────────────────────────────────────────────┐
│ 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.
npm install @pro-max-max/bridge-protocol
# or
yarn add @pro-max-max/bridge-protocol
# or
pnpm add @pro-max-max/bridge-protocolBoth 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, …).
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.
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 |
The host maintains a Set of granted capabilities. When the guest calls request(capability, payload):
- Host checks: is
capabilityin the policy set? → if no: respondCAPABILITY_DENIED. - Host checks: is there a registered handler? → if no: respond
HANDLER_MISSING. - Host calls the handler → on throw: respond
HANDLER_FAILEDorINVALID_PAYLOAD. - 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.
| Status | Capabilities |
|---|---|
| ✅ Safe default | All camera navigation, add/remove pins, add/remove polygons/polylines/circles, picking, viewport query, event subscriptions |
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) |
All 37 capabilities organized by group. Each entry shows the TypeScript payload and result from CapabilityIO.
| 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 |
| 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 } |
| 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 } |
| 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 } |
| 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 } |
✅ |
| Capability | Payload | Result | Safe |
|---|---|---|---|
cesium.picking.pickCoordinate |
PickAtScreenPayload |
PickCoordinateResult | null |
✅ |
cesium.picking.pickEntity |
PickAtScreenPayload |
PickEntityResult |
✅ |
| Capability | Payload | Result | Safe |
|---|---|---|---|
cesium.scene.requestRender |
{} |
{ ok: true } |
✅ |
cesium.scene.setTime |
SetTimePayload |
{ ok: true, iso: string } |
|
cesium.scene.getViewportBounds |
{} |
ViewportBounds |
✅ |
| 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 } |
🔴 |
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 } |
✅ |
| 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 |
All messages share the discriminated union shape:
{
channel: 'poc.bridge', // constant — used to filter out unrelated postMessages
kind: string, // discriminant
...fields
}Sent immediately when the guest's BridgeClient.start() runs.
{
channel: 'poc.bridge',
kind: 'ready',
protocolVersion: string,
}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[],
}Sent when the host refreshes the access token (user stays logged in, token rotates).
{ channel: 'poc.bridge', kind: 'auth-update', accessToken: string }Sent whenever the host admin changes the capability policy at runtime.
{ channel: 'poc.bridge', kind: 'policy-update', capabilities: CesiumCapability[] }{
channel: 'poc.bridge',
kind: 'request',
id: string, // correlation ID (unique per request)
capability: CesiumCapability,
payload: CapabilityPayload<typeof capability>,
}{
channel: 'poc.bridge',
kind: 'response',
id: string, // mirrors the request id
capability: CesiumCapability,
ok: boolean,
result?: CapabilityResult<typeof capability>,
error?: { code: BridgeErrorCode; message: string },
}Only sent for topics the guest has an active subscription for.
{
channel: 'poc.bridge',
kind: 'event',
topic: EventTopic,
payload: EventPayload<typeof topic>,
}// 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 }
}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
}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- A malicious host page (phishing/framebusting) — the guest should verify the
parentOriginit was configured with before accepting ahello. - 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
requestmessages; the host should add request throttling for production use.
The policy is an allowlist, not a denylist. A guest can only call capabilities that are:
- Declared in
CESIUM_CAPABILITIES(part of the contract). - Registered with a handler by the host (
host.register(cap, handler)). - Present in the host's current policy set (
host.setPolicy([...])).
All three must hold simultaneously.
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.
Events flow host → guest only, and only after the guest sends the corresponding *.subscribe capability request.
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(...)
│ │
If the host admin removes cesium.events.globeClick.subscribe from the policy while the guest has an active subscription:
- The host's
setPolicy()immediately removes the topic from its internal subscription set. - No further
eventmessages are sent for that topic. - The guest is notified via
policy-update(it loses the subscribe capability from its set). - A well-behaved guest implementation should detect the lost capability and mark the subscription as inactive in its UI.
| 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 |
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 |
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).
-
Protocol package (
@pro-max-max/bridge-protocol) — editindex.ts:- Add the string to
CESIUM_CAPABILITIES. - Add its
CapabilityDescriptortoCAPABILITY_CATALOG. - Add its
payload/resulttypes toCapabilityIO. - Bump
PROTOCOL_VERSION(MINOR). - Run
npm run build.
- Add the string to
-
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'toDEFAULT_POLICYif safe. -
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.
- Add
payloadtype toEventTopicIOinindex.ts. - Add the topic string to
EVENT_TOPICS. - Add
subscribe+unsubscribecapability entries everywhere. - In the host's
BridgeHost.ts: add the topic → subscribe-capability mapping inTOPIC_TO_SUBSCRIBE_CAP. - In the host implementation: call
host.emit(topic, payload)when the Cesium event fires.
See CHANGELOG.md.