@pro-max-max/bridge-protocol
v1.1.1
Published
Capability-based postMessage protocol for Cesium viewer host ↔ iframe guest communication. Defines the bridge envelope, 37 typed capabilities, error codes and handshake payloads.
Maintainers
Readme
@pro-max-max/bridge-protocol
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.
Table of contents
- 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
1. Overview
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.
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-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, …).
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.subscribeEach 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):
- 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.
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_ORIGINWhat the protocol does NOT protect against
- 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.
Capability allowlist philosophy
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.
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:
- 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.
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)
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.
Adding a new event topic
- 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.
14. Changelog
See CHANGELOG.md.
