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.
Note
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.
Note
When to Build a Bridge by Hand
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.
Prerequisites
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-embedding component 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.
How the Protocol Works
Communication happens in two phases.
1Salesforce host (LWC) Your app (React or any framework)2 | |3 | 1. window.postMessage handshake | (bootstrap only)4 |<---------- heartbeat ------------|5 |--------- MessagePort transfer -->|6 | |7 | 2. JSON-RPC 2.0 over the port | (everything else)8 |<================================>|
Handshake (window.postMessage). The host loads your app in a cross-origin iframe and passes hostMetaData (which includes hostAppOrigin and instanceId) on the URL. Your app sends an sf-embedding/ready heartbeat, and the host replies by transferring a MessagePort.
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. MessageChannel delivers messages in order per port.
Do You Need JSON-RPC?
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-embedding host emits and parses JSON-RPC 2.0 on the port. Requests are { jsonrpc: "2.0", id, method, params }, notifications drop the id, and responses carry the id plus a result or an error. The id is 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.postMessage messages. JSON-RPC begins only after the MessagePort transfer.
Step 1: Complete the Handshake
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.
1// Runs at document start, before any bridge is created.2function bootstrap({signal} = {}){3 return new Promise((resolve, reject)=>{4 if(window.parent === window){5 // Loaded directly, not embedded. Stay silent so your app still works standalone.6 return reject(new Error("NOT_EMBEDDED"));7}8 // hostAppOrigin pins where the heartbeat is sent and is the only trusted origin for this session.9 // instanceId binds the incoming envelope to this specific embed.10 const meta = JSON.parse(new URL(location.href).searchParams.get("hostMetaData") ?? "null");11 const hostAppOrigin = meta?.hostAppOrigin;12 const instanceId = meta?.instanceId;13 if(14 typeof hostAppOrigin !== "string" ||15 !hostAppOrigin ||16 typeof instanceId !== "string" ||17 !instanceId18){19 return reject(new Error("BAD_HOST_METADATA")); // fail fast; don't half-initialize20}2122 let captured = false;23 function onMessage(event){24 const d = event.data;25 // Five checks; all must pass before you trust the port:26 if(d?.type !== "sf-embedding.bootstrap" || d?.protocol !== "sf-embedding")return; // 1. type and protocol27 if(event.origin !== hostAppOrigin)return; // 2. only the host origin from hostMetaData28 if(event.source !== window.parent)return; // 3. only from the parent window29 if(event.ports.length !== 1)return; // 4. exactly one transferred port30 if(d.instanceId !== instanceId)return; // 5. bound to this embed3132 if(captured){33 // A second valid envelope means someone is racing the host. You can't tell which is real,34 // so fail closed: tell the host to shut this session down, and don't use the new port.35 parent.postMessage(36{type: "sf-embedding/shutdown", reason: "DUPLICATE_PORT_TRANSFER"},37 hostAppOrigin,38);39 return;40}41 captured = true;42 const port = event.ports[0];43 port.start();44 resolve({port, hostAppOrigin, instanceId});45 // Keep the listener registered so a later duplicate envelope still triggers the shutdown above.46}4748 window.addEventListener("message", onMessage);49 signal?.addEventListener("abort", ()=> reject(new Error("BOOTSTRAP_ABORTED")), {once: true});50 // Send the heartbeat only after the listener is registered, so you can't miss the reply.51 // protocolVersion here is how you select the protocol version; there is no separate handshake for it.52 parent.postMessage(53{type: "sf-embedding/ready", instanceId, protocolVersion: "1.0.0"},54 hostAppOrigin,55);56});57}
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.
Important
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.
Step 2: Build the Bridge
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.
1function createBridge(port){2 let nextId = 1;3 const pending = new Map(); // id -> { resolve, reject }4 const handlers = new Map(); // method -> (params) => result | Promise56 // Don't JSON.stringify. MessagePort uses structured clone; pre-stringifying breaks it.7 port.onmessage = ({data: msg})=>{8 if(msg.id != null && ("result" in msg || "error" in msg)){9 // A reply to a request we sent.10 const p = pending.get(msg.id);11 if(!p)return;12 pending.delete(msg.id);13 msg.error ? p.reject(msg.error) : p.resolve(msg.result);14}else if(msg.method){15 // An inbound request (has id) or notification (no id) from the host.16 Promise.resolve(handlers.get(msg.method)?.(msg.params)).then((result)=>{17 if(msg.id != null)port.postMessage({jsonrpc: "2.0", id: msg.id, result});18});19}20};2122 return{23 // Fire-and-forget.24 notify:(method, params)=> port.postMessage({jsonrpc: "2.0", method, params}),25 // Request and response. All shipping requests are quick, so one default timeout is enough.26 request:(method, params, timeoutMs = 30_000)=>27 new Promise((resolve, reject)=>{28 const id = nextId++;29 pending.set(id, {resolve, reject});30 if(timeoutMs)31 setTimeout(()=>{32 if(pending.delete(id))reject(new Error(`TIMEOUT ${method}`));33}, timeoutMs);34 port.postMessage({jsonrpc: "2.0", id, method, params});35}),36 // Handle inbound host requests and notifications.37 on:(method, handler)=> handlers.set(method, handler),38};39}
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.
Important
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.
Note
Step 3: Complete the Init Lifecycle and Subscribe to Host State
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 (instanceId and hostInfo).
You reply once with ui/notifications/embedding-initialized (your app’s name and version).
You subscribe to ui/subscribe/ui-state to receive the host’s props and styles. The current snapshot arrives in the response, not as a separate notification.
1const{port} = await bootstrap();2const bridge = createBridge(port);34// Register inbound handlers before announcing, so a fast host can't get ahead of you.5bridge.on("ui/notifications/host-initialized", (p)=>{6 /* p.instanceId, p.hostInfo */7});8bridge.on("ui/notifications/ui-state-changed", ({current})=>9 render(current.props, current.styles),10);11bridge.on("ui/events/dispatch", ({eventType, detail})=> handleHostEvent(eventType, detail));1213// Announce your app once.14bridge.notify("ui/notifications/embedding-initialized", {15 embeddingInfo:{name: "my-app", version: "1.0.0"},16});1718// Subscribe to host-driven props and styles; the snapshot arrives in the response.19const{subscriptionId, current} = await bridge.request("ui/subscribe/ui-state", {});20render(current.props, current.styles);
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.
Note
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.
Use the Bridge
With the bridge in place, every protocol method is a request or a notification.
1// Request with a default 30-second timeout. Subscribe to host-driven state:2const{current} = await bridge.request("ui/subscribe/ui-state", {});34// Notification, fire-and-forget. Hint a new size:5bridge.notify("ui/notifications/resize", {height: 800});67// Notification. Bubble a custom event onto the host <lightning-ui-embedding> element:8bridge.notify("ui/events/dispatch", {eventType: "orderselected", detail:{id: "O-123"}});910// Receive an event the host forwards to you (after ui/events/subscribe):11bridge.on("ui/events/dispatch", ({eventType, detail})=> handleHostEvent(eventType, detail));
A few rules are worth internalizing.
Every request gets a response. Notifications never reply. A request resolves with a result or rejects with an error. 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/resize is 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 through window.innerWidth and window.innerHeight if you need to.
Dirty-state rides on the event channel. There’s no dedicated method for it. Dispatch ui/events/dispatch with eventType: "trackdirtystate" and detail: { isDirty, instanceId, label }. Use the instanceId from the handshake. Omit it and the host drops the signal. The host re-emits trackdirtystate as a DOM event that a page author can bind to.
Protocol Messages
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.
Note
Error Responses
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.
Security Essentials
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. Verify event.origin === hostAppOrigin on 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 MessagePort in local or module scope. Don’t put it on window, 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.
Protocol Versions
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.