Build the Embedding Bridge Without a Salesforce Package
Embed an external web app in Salesforce and talk to the host without adding a @salesforce/* package to your app. An embedded app communicates with Salesforce over the sf-embedding protocol through a small piece of JavaScript called the bridge: it completes a one-time handshake, then exchanges JSON-RPC 2.0 messages with the host over a MessagePort. This guide shows how to build that bridge yourself, in plain JavaScript, and covers the message surface the deployed host implements today.
The sf-embedding protocol specification normatively defines the behavior described here. Where this guide and the specification disagree, the specification is authoritative.
Scope. This guide covers the surface the deployed lightning-ui-embedding host implements today: the handshake and version selection, the init lifecycle, host-driven UI state, custom events, resize, and error reporting. The specification defines a larger future surface, including a capability model, host-rendered UI (alert, toast, modal, prompt, and confirm dialogs), host navigation, and request cancellation, that the shipping host doesn’t broker yet. Those aren’t covered here. The shipping host doesn’t gate methods by capability.
This guide covers the guest side only: the bridge that runs inside your embedded app. It doesn’t replace the host setup. On the Salesforce side, an admin or developer still renders the lightning-ui-embedding base component pointed at your app’s URL and adds a CSP Trusted Site for your app’s origin. For that path, see Get Started with UI Embedding.
Most apps use the Salesforce-provided guest SDK to talk to the host. Build the bridge yourself when you want full control over the protocol surface, want to avoid an added dependency, or want to integrate the protocol into an existing message layer. A hand-written, wire-level bridge is fully supported as long as it honors the message shapes described here.
- Your web app is hosted at an HTTPS URL that you control, on an origin that differs from your Salesforce org’s origin.
- A Salesforce host renders the
lightning-ui-embeddingcomponent pointed at your app, and the org trusts your app’s origin for inline frames. See Get Started with UI Embedding. - You’re comfortable working with
window.postMessage,MessageChannel, and JSON-RPC 2.0 message shapes.
Communication happens in two phases.
- Handshake (
window.postMessage). The host loads your app in a cross-origin iframe and passeshostMetaData(which includeshostAppOriginandinstanceId) on the URL. Your app sends ansf-embedding/readyheartbeat, and the host replies by transferring aMessagePort. - Session (JSON-RPC 2.0 over the port). Every later message, whether a request, a response, or a notification, is standard JSON-RPC 2.0 framed over that port.
MessageChanneldelivers messages in order per port.
A JSON-RPC library isn’t required, but the JSON-RPC 2.0 message shape is required if you want to talk to the real Salesforce host. Keep two things separate.
- The message shape is the host’s contract. The deployed
lightning-ui-embeddinghost emits and parses JSON-RPC 2.0 on the port. Requests are{ jsonrpc: "2.0", id, method, params }, notifications drop theid, and responses carry theidplus aresultor anerror. Theidis a monotonic, non-negative integer per side. A request can carry an optional_meta.traceId, which the host echoes on the reply. The host doesn’t understand anything else on the port. That’s a data format, not a dependency, so you can hand-build those objects, which is what the bridge in Step 2 does with no library. - The handshake is not JSON-RPC. The heartbeat and bootstrap envelope are plain
window.postMessagemessages. JSON-RPC begins only after theMessagePorttransfer.
The handshake is Salesforce-specific and security-critical, so follow it exactly. Read hostMetaData, send the heartbeat, and then wait for the MessagePort inside an sf-embedding.bootstrap envelope. Validate the envelope with five checks before you trust it.
There’s no bootstrap timeout. Silence isn’t failure. The iframe can legitimately sit on a login, multi-factor authentication (MFA), or consent screen before your app loads and sends its heartbeat.
Run the handshake once, as early as possible, such as a module-load side effect or a mount effect on your top-level layout, and reuse the single session promise everywhere. If you bootstrap lazily on first feature use, any screen that doesn’t touch the bridge on mount never handshakes. The failure is invisible because no error fires and the feature does nothing.
After the handshake, communication is plain JSON-RPC 2.0, so the bridge is small. It generates request IDs, correlates replies, dispatches inbound requests and notifications by method, and times out requests. Here’s the whole bridge, hand-written with no dependencies.
Don’t JSON.stringify payloads. MessagePort.postMessage runs the structured clone algorithm, which preserves Date, Map, Set, and typed arrays. Pass JSON-RPC objects to it directly, and read event.data directly rather than re-parsing a string. Every value you send must be structured-clone-safe.
Prefer a maintained library? Any conformant JSON-RPC 2.0 library works, and the rest of this guide is identical. Whatever you use, pin the version, and confirm that it emits standard JSON-RPC 2.0, never auto-retries (which can duplicate a side effect), and never pre-stringifies payloads.
Right after the port is live, the host announces itself and you announce back. Then you subscribe to the host’s rendering state. Register your inbound handlers before you announce, so a fast host can’t get ahead of you.
- The host sends
ui/notifications/host-initialized, the authoritative carrier of session identity (instanceIdandhostInfo). - You reply once with
ui/notifications/embedding-initialized(your app’s name and version). - You subscribe to
ui/subscribe/ui-stateto receive the host’s props and styles. The current snapshot arrives in the response, not as a separate notification.
There’s at most one active UI-state subscription per session. A second ui/subscribe/ui-state while one is active rejects with INVALID_PARAMS. Snapshots are full replacements, never diffs, and the host batches a multi-property update in one JavaScript turn into a single ui/notifications/ui-state-changed.
If you send a request before the init lifecycle finishes, the host rejects it with NOT_INITIALIZED. Wait for ui/notifications/host-initialized, and then send your requests.
With the bridge in place, every protocol method is a request or a notification.
A few rules are worth internalizing.
- Every request gets a response. Notifications never reply. A request resolves with a
resultor rejects with anerror. Only notifications are dropped silently, because they have no response path. - One default timeout is enough. All shipping requests are quick, and the shipping host has no user-gated (indefinite) requests, so you don’t need to extend or disable the timeout for any method.
- Notifications are fire-and-forget.
ui/notifications/resizeis a size hint; the host applies its own layout policy and can honor, clamp, or ignore it. There’s no confirming response, so observe the outcome throughwindow.innerWidthandwindow.innerHeightif you need to. - Dirty-state rides on the event channel. There’s no dedicated method for it. Dispatch
ui/events/dispatchwitheventType: "trackdirtystate"anddetail: { isDirty, instanceId, label }. Use theinstanceIdfrom the handshake. Omit it and the host drops the signal. The host re-emitstrackdirtystateas a DOM event that a page author can bind to.
These are the messages a bridge uses over the port. None of them needs a Salesforce package, because you send and receive them all through the bridge. The handshake messages (the sf-embedding/ready heartbeat and the sf-embedding.bootstrap envelope) travel over window.postMessage, not the port. Every message in the table is JSON-RPC 2.0 over the port.
| Message | Direction | Kind | Purpose |
|---|---|---|---|
ui/notifications/host-initialized | host to app | notification | Host announces itself (instanceId, hostInfo) |
ui/notifications/embedding-initialized | app to host | notification | Your app announces itself (name, version) |
ui/subscribe/ui-state | app to host | request | Subscribe to host-driven props and styles; the response carries the snapshot |
ui/unsubscribe/ui-state | app to host | request | Dispose the UI-state subscription |
ui/notifications/ui-state-changed | host to app | notification | Full UI-state snapshot on every change |
ui/events/subscribe | app to host | request | Subscribe to receive host-forwarded custom events |
ui/events/unsubscribe | app to host | request | Dispose the events subscription |
ui/events/dispatch | both | notification | Custom event either way; dirty-state rides on this |
ui/notifications/resize | app to host | notification | Fire-and-forget size hint (width and height in CSS pixels) |
ui/notifications/error | app to host | notification | Sanitized error report (no message or stack; capped at about 50 per session) |
This table is the full surface the deployed lightning-ui-embedding host implements today. The protocol specification defines a larger future surface, including a capability model, host-rendered UI (alert, toast, modal, prompt, and confirm dialogs), host navigation, and request cancellation, that the shipping host doesn’t broker yet.
Every request gets a response. A failure comes back as a JSON-RPC error with a numeric code, an optional message, and optional data ({ retryable?, details? }). These are transport-level codes on the port, distinct from the component error codes reported on the sf-embedding.component.error DOM event. For those, see UI Embedding Error Codes.
| Code | Constant | Meaning and what to do |
|---|---|---|
| -32700 | PARSE_ERROR | Malformed frame received. Fix the payload. |
| -32600 | INVALID_REQUEST | Not a valid JSON-RPC request object. Fix the envelope. |
| -32601 | METHOD_NOT_FOUND | Method unknown at the selected version. The feature isn’t in this host version. |
| -32602 | INVALID_PARAMS | Params failed validation, such as a second ui/subscribe/ui-state or an unknown subscriptionId. Fix your params or subscription logic. |
| -32603 | INTERNAL_ERROR | Host-side failure handling a valid request. Retry if transient. |
| -32012 | NOT_INITIALIZED | Request sent before the session finished initializing. Wait for the init lifecycle, then retry. |
| -32016 | PORT_CLOSED | Session torn down; pending requests reject locally. Remount to reconnect. |
| -32017 | SERIALIZATION_FAILURE | A payload wasn’t structured-clone-safe. Send clone-safe values. |
| -32020 | PROTOCOL_VIOLATION | Contract broken, such as a duplicate lifecycle message. Fix ordering. |
| -32013 | PROTOCOL_VERSION_MISMATCH | Heartbeat version not supported by the host. Pin a supported version. Surfaces at the handshake as a component error DOM event, before any port exists. |
| -32018 | REQUEST_TIMEOUT | Client-synthesized when a request outlives its timeout. Retry with backoff. |
METHOD_NOT_FOUND and INVALID_PARAMS are distinct. METHOD_NOT_FOUND means the host doesn’t know the method. INVALID_PARAMS means the method exists but your arguments are wrong. REQUEST_TIMEOUT is a client-side convention your bridge synthesizes, not a host error.
The bootstrap you hand-write is security-critical.
- Serve your app from a different origin than the host. The browser’s Same-Origin Policy isolates it. In local development, different ports count as different origins.
- Trust only
hostAppOrigin. Verifyevent.origin === hostAppOriginon the envelope, and send the heartbeat to that exact origin, never"*". Don’t maintain your own list of accepted host origins. - Run all five envelope checks, and fail closed on a duplicate transfer. A second valid envelope means you shut the session down and never use the second port. The worst outcome is a denied session, never a hijacked one.
- Never let the port leak. Keep the
MessagePortin local or module scope. Don’t put it onwindow,globalThis, or any shared registry. - You handle your own authentication. The host gives you no credentials or data access, so authenticate to your own backend as usual.
- Sanitize error reports. Send only the method name, a category, and a path-only filename. Never send a message, a stack, or a reason.
Your app declares the single version it speaks in the heartbeat (protocolVersion: "1.0.0"). The host accepts it by transferring the port. To refuse the session, the host doesn’t transfer a port. Instead, it dispatches sf-embedding.component.error (code PROTOCOL_VERSION_MISMATCH) on its own element for the page to observe. The shipping host supports exactly 1.0.0. Pin to a version you’ve tested, and watch the protocol changelog.