@ada-cx/messaging-bridge
v1.3.0
Published
Types, test mocks, and a thin CDN loader for building a custom app UI on Ada Messaging's bridge contract. The bridge runtime always loads from Ada's CDN.
Readme
@ada-cx/messaging-bridge
Build your own chat UI (a "custom app") on Ada Messaging's bridge contract.
This package carries TypeScript types, test mocks, and a thin loader.
Ada core stamps its build identifier into your app frame's window.name. The loader uses it to load the matching bridge runtime.
Your installed package never contains the runtime. Ada can update runtime security logic through a coordinated core release.
Requirements
- Your custom app runs inside an iframe that Ada's core frame mounts. The core frame is served from Ada's asset host, and it sits inside the page that embeds the widget. Both origins are in your app's ancestor chain.
- You point the Web SDK at your app with its
appUrlsetting. - Your app's origin must be in your handle's Allowed websites list. Add
it in your Ada dashboard, under Channels > Chat. Until the list
allows your origin, a configured
appUrlis dropped with a console warning and Ada's default app mounts (withappUrlFallback: falsethe activation fails instead). An entry that carries a path, query, or fragment does not authorize a custom app. Add the bare origin as its own entry. Local development needs no entry when both your app and the embedding page run on loopback hosts. - If your app's responses send no
X-Frame-Optionsheader and no CSPframe-ancestorsdirective, browsers permit framing, and no server change is needed. If your app restricts framing,frame-ancestorsmust allow Ada's asset hosts and every site origin that embeds the widget:
Content-Security-Policy: frame-ancestors https://messaging-assets.ada.support https://static.ada.support https://your-site.comReplace https://your-site.com with the origins of the pages that embed
the Ada widget. Browsers check frame-ancestors against every ancestor
frame, so a policy that lists only Ada's hosts blocks your app.
Quick start
Install the package:
npm install @ada-cx/messaging-bridgeLoad the runtime, complete the handshake, then render from state:
import { loadMessagingBridge } from "@ada-cx/messaging-bridge";
const bridge = await loadMessagingBridge();
const client = bridge.createBridgeClient();
// REQUIRED: send app.initialize within 15 seconds of your frame loading.
// If the handshake does not arrive, core unmounts your frame.
client.sendEvent("app.initialize");
// Subscribe to display-state updates from core.
const unsubscribe = client.subscribe(() => {
const state = client.getState();
renderMessages(state?.["chat.messages"] ?? []);
});
// Send a user message through the operations layer.
const handle = client.operations.sendMessage("Hello");
const message = await handle.settled();
// Tear down when your app unmounts.
unsubscribe();
client.destroy();loadMessagingBridge() memoizes the load. Repeated calls return the same
promise. A failed load is forgotten, so a later call retries.
Use typed state keys through the loaded module:
const botName = client.getState()?.[bridge.STATE.CONFIG_BOT_NAME];Operations
client.operations provides typed helpers over the raw event contract. Each
helper carries the guards, debounces, and correlation logic that Ada's own
app uses. Prefer them over hand-built sendEvent calls.
// Correlated send: the handle resolves with the message row.
const handle = client.operations.sendMessage("Hello");
const message = await handle.settled({ timeoutMs: 10_000 });
// Read tracking: debounced, monotonic, reset per conversation.
client.operations.markRead(message.cursor ?? "");
// Correlated survey submit.
const result = await client.operations
.submitCsat({ score: 5 }, { surveyType: "bot", conversationId })
.settled();Highlights:
sendMessage(body, { secret? })returns a handle.settled()resolves with the message once it appears inchat.messages, and rejects when core reports a send error first (rate limit, over-length body, session not ready, dispatch failure) — detected on thechat.error.seqadvance, so a repeat of the identical error text still rejects. A secret send writes no transcript row, so its handle never resolves. PasstimeoutMsor skipsettled()for secret sends.submitCapture(value).settled()reports the server verdict for the active capture field.checkEndChatEligibility()resolves with the End Chat survey decision.endLiveChat()ends only the live-agent leg. On a bot withconfig.features.endLiveChat, call it — notskipCsatAndEndChat()or anend_chatsubmitCsat()— when End Chat is confirmed during a live chat (liveAgent.inLiveChat), and offer no end-chat survey: api routes the post-agent survey as an inlinelive_chatrow once the leg has ended. OnceliveAgent.liveChatEndedInConversationis true on such a bot, End Chat callsskipCsatAndEndChat()directly, with no confirmation or eligibility check.trackCsatShown("live_chat")is dropped by core: api records that survey as shown when it routes its row.markRead(cursor)debounces 400 ms and keeps a monotonic watermark. Do not reimplement read tracking.startNewConversation()applies a 5 second cooldown and returnsfalsewhile cooling down or whileapp.conversationStartPendingis true. A pending refusal does not extend the cooldown.truemeans the request was sent.- A restart requested while proactive requests are in flight waits for them.
Render both restart controls busy from
app.conversationStartPending(absent means false). Subscribe withclient.subscribeKeyor read React bridge state. A successful proactive keeps its messages and prior transcript; a completed success awaiting conversation delivery can satisfy one restart too. Another explicit restart after completion is a new intent, allowing recovery when no conversation arrives. Without pending work or an unused success awaiting delivery, the normal reset begins immediately. If all fail and the request remains current, Core performs the latest accepted reset once. A newer conversation/session or an accepted user send or retry cancels that wait; rejected sends leave it intact.resetApp()still sends while pending. Wait for Core to clear the pending state; it is transient and is not a conversation-start completion acknowledgement. startFileUpload(file)retains the{ file, uploadId }pair soretry()replays exactly it.notifyComposerChanged()is payload-free and suppressed in secret mode. Composer text never crosses the frame boundary.- Chrome and settings helpers:
close,minimize,dismissError,setLanguage,setTheme,emailTranscript, and more. See theBridgeOperationstype for the full surface.
Use outage.connectivityLost as the connectivity signal. Do not substitute
navigator.onLine. It reports false positives on VPN and virtual-adapter
transitions.
Observation
client.subscribeKey(key, callback) fires only when one key's value
changes. client.select(selector, callback) does the same for a derived
projection. Both default to Object.is equality and accept a custom
equals. Core reuses references for unchanged keys, so identity equality
is sound for every key except chat.messages.
client.subscribeKey("chat.isGenerating", (generating) => {
toggleTypingIndicator(generating === true);
});Derivation helpers
The loaded module exports pure helpers mined from Ada's reference app:
messageKey, isHistoricalRow, findFirstUnread, selectUnread,
groupMessages, filterDisplayable, resolveBotName,
resolveDisplayBotName, isAgentTyping, and isConnectivityLost.
resolveBotName is the attribution name. Use it for sender labels and
announcements. It falls back to the bot handle.
resolveDisplayBotName is the display name for your own chrome, such as a
header title or a greeting. It is empty when the customer configured no bot
name, so a handle never reaches the page as a name.
resolveDisplayBotName is the one optional helper. The mounting core frame
picks the runtime build, not your installed package version. A build older than
this helper can therefore load. This happens during a rollout, behind an
?ada-messaging-version pin, or after a rollback. Guard the call:
const name = bridge.resolveDisplayBotName?.(state) ?? "";const { messageKey, filterDisplayable, groupMessages } = bridge;
const rows = filterDisplayable(messages, state?.["chat.isConversationActive"] ?? false);
const grouping = groupMessages(rows);Use messageKey as your render key. Raw message ids change twice: a
streaming bubble swaps to its final id, and an optimistic send swaps to its
durable id. Keying on the raw id replays animations and breaks unread
anchors.
Options
| Option | Default | Purpose |
| --- | --- | --- |
| cdnBase | https://messaging-assets.ada.support | Asset origin for immutable bridge builds. It must use HTTPS. Plain HTTP works only for loopback development. Override it only when your Ada team directs you to during a joint validation. |
Frame-only asset URL resolution
resolveBridgeCdnUrl() returns the bridge asset URL selected for the current custom app frame.
It uses the same validation as loadMessagingBridge().
This helper is frame-only. It throws a MessagingBridgeLoadError outside an Ada-mounted custom app frame.
Call it after your app starts inside that frame. Do not evaluate it at module scope in code that also runs elsewhere.
Errors: loadMessagingBridge rejects with a MessagingBridgeLoadError. Its
code property identifies the failure:
| Code | Meaning |
| --- | --- |
| invalid_cdn_base | The cdnBase option violates the HTTPS or loopback HTTP policy. |
| missing_build_sha | The frame's window.name carries no core build marker, or the mounting core predates build-marker delivery. |
| invalid_build_sha | The frame-name marker matches no accepted build identifier form. |
| dev_marker_not_loopback | The frame uses the dev marker with a non-loopback asset host. |
| bridge_import_failed | The asset failed to load (network, CSP, 404). |
| bridge_module_invalid | The loaded module is not an Ada bridge build. |
Matching the core build
Ada core mounts your frame with window.name set to ada-custom-app:<build identifier> before your app code runs. Your URL and its fragment mount untouched, so hash routes keep working.
| Marker | Meaning | Resolved asset |
| --- | --- | --- |
| A bare 40-character hexadecimal SHA | An immutable production build. | <cdnBase>/<sha>/bridge/bridge.js |
| Lowercase pr-<N> | An Ada-internal preview build. | <cdnBase>/pr-<N>/bridge/bridge.js |
| dev | An Ada-internal local core build. Customer pages never receive this marker. | <cdnBase>/bridge/bridge.js on loopback only. |
The loader accepts only these marker shapes. It never accepts a URL or path from the frame name.
The loader snapshots window.name when your app first evaluates the package, through either entry: @ada-cx/messaging-bridge or @ada-cx/messaging-bridge/react. Keep that import in a module your page evaluates at startup. A route-level code split evaluates too late. Code that runs first, for example a library that uses window.name, could overwrite the name. If the route that renders AdaBridgeProvider is lazily loaded, add a bare import "@ada-cx/messaging-bridge"; to your entry module. The loader keeps the snapshot value if code overwrites window.name later.
Do not persist or replay the frame-name marker. Core supplies the authoritative value for every frame load.
Do not set or change window.name. The loader fails closed when your page runs outside an Ada-mounted custom app frame.
The dev marker and PR preview aliases are Ada-internal workflows. Local Ada core builds stamp ada-custom-app:dev, which works only with a loopback cdnBase. An Ada preview core selects its matching preview bridge automatically. You do not need a cdnBase override for a joint validation when the page loads sdk.js from the production asset root. A preview alias against an asset host that lacks the preview fails closed.
React
The ./react entry provides a provider and hooks. They delegate to a
BridgeClient instance. They contain no runtime logic of their own.
The simplest path handles loading, the app.initialize handshake, and
teardown for you:
import { createBridgeProvider, useBridgeState } from "@ada-cx/messaging-bridge/react";
const AdaBridgeProvider = createBridgeProvider();
function Root() {
return (
<AdaBridgeProvider fallback={<Spinner />}>
<MyChatUi />
</AdaBridgeProvider>
);
}
function MyChatUi() {
const state = useBridgeState();
return <MessageList messages={state?.["chat.messages"] ?? []} />;
}If your app lazily loads the route that renders AdaBridgeProvider, add a
bare import "@ada-cx/messaging-bridge"; to your entry module. The loader
then snapshots the frame name at startup, before other code can overwrite
window.name. A statically imported createBridgeProvider already evaluates
the loader through a used binding; the bare import matters only when that
import itself lives in a lazily loaded chunk.
The provider accepts two more props for load failures. errorFallback
renders when the runtime fails to load. onError receives the
MessagingBridgeLoadError. Without them, the provider logs the error and
keeps rendering fallback.
To own the lifecycle yourself, pass a client you created:
import { BridgeProvider } from "@ada-cx/messaging-bridge/react";
<BridgeProvider client={client}>
<MyChatUi />
</BridgeProvider>With an injected client, you send app.initialize and call destroy()
yourself.
Hooks: useBridgeClient(), useBridgeState(), and useBridgeStateKey(key).
useBridgeStateKey re-renders only when its key's value changes.
useBridgeState re-renders on every state update. Reach the operations
layer through useBridgeClient().operations.
Testing
The ./testing entry provides a scriptable in-memory client for unit tests.
It performs no postMessage and no network access.
import { createMockBridgeClient } from "@ada-cx/messaging-bridge/testing";
const mock = createMockBridgeClient();
render(<BridgeProvider client={mock}><MyChatUi /></BridgeProvider>);
mock.updateState({ "chat.isSending": true });
expect(mock.sentEvents).toContainEqual({
event: "chat.message.send",
payload: expect.objectContaining({ body: "Hello" }),
});Mock helpers: setState, updateState, sentEvents, clearSentEvents,
subscriberCount, and destroyed.
The mock implements the full client surface, operations included. Every
operation records its events in sentEvents. A correlated handle resolves
when your test scripts the answering state change:
const handle = mock.operations.sendMessage("Hello");
mock.updateState({
"chat.messages": [
{ type: "text", id: "m1", sender: "user", timestamp: 1,
body: "Hello", clientKey: handle.tempMessageUuid },
],
});
await handle.settled();Contract notes
AppDisplayStateis the full state shape core publishes.AppEventsmaps each sendable event to its payload type.app.initializeis mandatory. Send it within 15 seconds of frame load.- The runtime derives the core origin from
document.referrer, or locks it on the first valid state message. It rejects state from other origins. - In Ada's custom-app frame, the first
app.initializehands core aMessagePort. All later state and events ride that port. The port is pinned to your first document, so your app must not navigate or reload its own frame. A navigation disconnects the bridge permanently. - The runtime detects the custom-app frame by the frame name core sets
(
ada-custom-app:<build identifier>), which also selects the bridge build the loader imports. Do not changewindow.nameinside your app document. - The custom-app frame keeps your real origin, so your own cookies and
storage work inside it. Browsers partition third-party storage by the
embedding site. Cookies need
Partitioned; Secure; SameSite=None. - The custom-app frame needs
document.referrerto target the handshake. Core provides the referrer by default. If a browser extension or policy strips the Referer header, the handshake cannot send. The runtime logs an error, and core falls back to Ada's default app after 15 seconds.
Support
This package supports Ada's custom app program. Open issues through your Ada support contact.
