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       |<================================>|
  1. 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.
  2. 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      !instanceId
18    ) {
19      return reject(new Error("BAD_HOST_METADATA")); // fail fast; don't half-initialize
20    }
21
22    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 protocol
27      if (event.origin !== hostAppOrigin) return; // 2. only the host origin from hostMetaData
28      if (event.source !== window.parent) return; // 3. only from the parent window
29      if (event.ports.length !== 1) return; // 4. exactly one transferred port
30      if (d.instanceId !== instanceId) return; // 5. bound to this embed
31
32      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    }
47
48    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 | Promise
5
6  // 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  };
21
22  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);
3
4// 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));
12
13// Announce your app once.
14bridge.notify("ui/notifications/embedding-initialized", {
15  embeddingInfo: { name: "my-app", version: "1.0.0" },
16});
17
18// 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", {});
3
4// Notification, fire-and-forget. Hint a new size:
5bridge.notify("ui/notifications/resize", { height: 800 });
6
7// Notification. Bubble a custom event onto the host <lightning-ui-embedding> element:
8bridge.notify("ui/events/dispatch", { eventType: "orderselected", detail: { id: "O-123" } });
9
10// 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.

MessageDirectionKindPurpose
ui/notifications/host-initializedhost to appnotificationHost announces itself (instanceId, hostInfo)
ui/notifications/embedding-initializedapp to hostnotificationYour app announces itself (name, version)
ui/subscribe/ui-stateapp to hostrequestSubscribe to host-driven props and styles; the response carries the snapshot
ui/unsubscribe/ui-stateapp to hostrequestDispose the UI-state subscription
ui/notifications/ui-state-changedhost to appnotificationFull UI-state snapshot on every change
ui/events/subscribeapp to hostrequestSubscribe to receive host-forwarded custom events
ui/events/unsubscribeapp to hostrequestDispose the events subscription
ui/events/dispatchbothnotificationCustom event either way; dirty-state rides on this
ui/notifications/resizeapp to hostnotificationFire-and-forget size hint (width and height in CSS pixels)
ui/notifications/errorapp to hostnotificationSanitized 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.

CodeConstantMeaning and what to do
-32700PARSE_ERRORMalformed frame received. Fix the payload.
-32600INVALID_REQUESTNot a valid JSON-RPC request object. Fix the envelope.
-32601METHOD_NOT_FOUNDMethod unknown at the selected version. The feature isn’t in this host version.
-32602INVALID_PARAMSParams failed validation, such as a second ui/subscribe/ui-state or an unknown subscriptionId. Fix your params or subscription logic.
-32603INTERNAL_ERRORHost-side failure handling a valid request. Retry if transient.
-32012NOT_INITIALIZEDRequest sent before the session finished initializing. Wait for the init lifecycle, then retry.
-32016PORT_CLOSEDSession torn down; pending requests reject locally. Remount to reconnect.
-32017SERIALIZATION_FAILUREA payload wasn’t structured-clone-safe. Send clone-safe values.
-32020PROTOCOL_VIOLATIONContract broken, such as a duplicate lifecycle message. Fix ordering.
-32013PROTOCOL_VERSION_MISMATCHHeartbeat version not supported by the host. Pin a supported version. Surfaces at the handshake as a component error DOM event, before any port exists.
-32018REQUEST_TIMEOUTClient-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.

See Also