@finamx/host-bridge
v1.0.0
Published
Primitives for embedding FinamX extensions into host applications — web iframes, React apps, and MCP agents.
Downloads
13
Readme
@finamx/host-bridge
Primitives for embedding FinamX extensions into host applications — web iframes, React apps, and MCP agents.
Install
npm install @finamx/host-bridge @finamx/bridge-protocolQuick Start — Native-like embed (React)
import {
ExtensionEmbedFrame,
fetchEmbedLaunch,
launchToBridgeSession,
} from '@finamx/host-bridge';
const launch = await fetchEmbedLaunch({
extensionId: 'com.example.my-extension',
platformBase: process.env.NEXT_PUBLIC_CMS_URL!,
userId: 'poc-user-1',
});
<ExtensionEmbedFrame
session={launchToBridgeSession(launch)}
launchUrl={launch.launchUrl}
/>;ExtensionEmbedFrame applies server-driven embedLayout (height bounds, scroll policy, gesture mode), handles ui:resize, ui:scroll-lock, and ui:gesture-claim. See docs/architecture/native-embed-sdk.md.
Quick Start — Web Host
import { createWebHostBridge, CapabilityRouter, ExtensionSessionConfig } from '@finamx/host-bridge';
const config: ExtensionSessionConfig = {
sessionId: crypto.randomUUID(),
extensionId: 'com.example.my-extension',
allowedOrigin: 'https://my-extension.example.com',
grantedScopes: ['market.read', 'ui.toast'],
};
const bridge = createWebHostBridge({
iframe: document.getElementById('extension-frame') as HTMLIFrameElement,
allowedOrigin: config.allowedOrigin,
session: config,
});
// Tear down (removes window message listener):
bridge.destroy();Quick Start — Fill-container layout (D-04)
For host UI where the embed should fill a card or modal rather than using a fixed height:
import { ExtensionEmbedFrame } from '@finamx/host-bridge';
// The container div controls the available height; ExtensionEmbedFrame
// fills it and sends host:layout with measured px dimensions via ResizeObserver
// so the extension can respond with appropriate layout (not minHeight: 100vh).
<div style={{ height: 320 }}>
<ExtensionEmbedFrame
session={session}
launchUrl={launch.launchUrl}
fillContainer // iframe height = 100% of container; ResizeObserver pushes dimensions
/>
</div>With useExtensionEmbed, mount containerRef on the wrapper div:
const bridge = useExtensionEmbed({ ..., fillContainer: true });
<div ref={bridge.containerRef} style={{ height: '100%' }}>
<iframe ref={bridge.iframeRef} {...bridge.frameProps} />
</div>Quick Start — Design tokens + debug tooling
Pass real design tokens so embedded extensions inherit your host's theme instead of the generic shadcn defaults, and opt into bridge traffic / token inspection panels.
Unified debug kit (D-05, D-06)
BridgeHostDebugKit composes the log panel, token preview, and scope revoke controls
into a single opt-in component. Always gate it behind a debug flag — never render in
production embeds (T-48-02):
import {
useExtensionEmbed,
BridgeHostDebugKit,
CapabilityRouter,
} from '@finamx/host-bridge';
const [debug, setDebug] = useState(false);
const [grantedScopes, setGrantedScopes] = useState(session.grantedScopes);
const bridge = useExtensionEmbed({ ..., debug });
// Render conditionally — never default on in production
{debug && (
<BridgeHostDebugKit
entries={bridge.bridgeLog}
onClear={bridge.clearBridgeLog}
tokens={uiTokens}
grantedScopes={grantedScopes}
onScopesChange={setGrantedScopes} // simulate scope revoke (dev-only)
uiTokensEnabled={tokensEnabled}
onUiTokensEnabledChange={setTokensEnabled}
/>
)}Individual panels (manual composition)
import {
useExtensionEmbed,
BridgeDebugLogPanel,
BridgeTokenPreviewPanel,
CapabilityRouter,
} from '@finamx/host-bridge';
import { mergeUITokens, type UITokens } from '@finamx/bridge-protocol';
function readHostUITokens(): UITokens {
const style = getComputedStyle(document.documentElement);
const colors = { ...mergeUITokens({}).colors };
for (const key of Object.keys(colors) as (keyof UITokens['colors'])[]) {
const v = style.getPropertyValue(`--${key}`).trim();
if (v) colors[key] = v;
}
return mergeUITokens({ colors });
}
const capabilityRouter = useMemo(() => new CapabilityRouter(), []);
const uiTokens = useMemo(() => readHostUITokens(), []);
const [debugEnabled, setDebugEnabled] = useState(false);
const bridge = useExtensionEmbed({
launchUrl: launch.launchUrl,
allowedOrigin: session.allowedOrigin,
session,
capabilityRouter,
uiTokens,
debug: debugEnabled,
});
// ...render the iframe via bridge.iframeRef / bridge.frameProps as usual, then:
{debugEnabled && (
<BridgeDebugLogPanel entries={bridge.bridgeLog} onClear={bridge.clearBridgeLog} />
)}
<BridgeTokenPreviewPanel tokens={uiTokens} />bridgeLog/clearBridgeLog only populate while debug: true — leave it off by default and
gate the toggle behind an internal/dev flag, not exposed to end users.
Quick Start — MCP Agent
import { createMcpWidgetTools } from '@finamx/host-bridge/mcp';
const tools = createMcpWidgetTools({
widgetCatalog: [
{
extensionId: 'com.example.widgets',
name: 'Portfolio',
description: 'Portfolio summary widget',
routes: ['/widgets/portfolio-summary'],
},
],
launchWidget: async (extensionId, route, params) => {
// Return { launchUrl, sessionId } from your launch service
return {
launchUrl: `https://app.example.com/embed?ext=${extensionId}&route=${route}`,
sessionId: crypto.randomUUID(),
};
},
});
// Register with your MCP server:
// tools.forEach(t => server.tool(t.name, t.description, t.inputSchema, t.handler));Exports
Main package (@finamx/host-bridge)
| Export | Description |
|--------|-------------|
| createWebHostBridge(options) | Browser iframe bridge with automatic window message listener management |
| createHostBridge(options) | Low-level factory — caller manages window events |
| createCapabilityRouter(options?) | Factory helper for CapabilityRouter |
| CapabilityRouter | Validates scopes, dispatches platform:invoke to handler |
| ExtensionEmbedFrame | Drop-in React iframe with embed layout + scroll-lock side effects |
| resolveEmbedLayout() | Merge session embedLayout with defaults and overrides |
| getEmbedFrameStyles() | CSS for iframe from layout + scroll/gesture state |
| runScrollLockSideEffects() | Apply parent scroll lock when extension sends ui:scroll-lock |
| sendHostLayoutEvent() / sendHostScrollStateEvent() | Dispatch platform:event to embed |
| useExtensionEmbed(options) | React hook — iframe ref, frameProps, layout, lock/claim state, bridgeLog, bridgeReady |
| fetchEmbedLaunch(options) | Calls POST /api/v1/embed/launch, returns LaunchResponse |
| launchToBridgeSession(launch, userId?) | Converts LaunchResponse → BridgeSessionConfig |
| ExtensionSessionConfig | Session config with optional uiTokens and embedLayout |
| UITokens | Design token shape (re-exported from @finamx/bridge-protocol) |
| BridgeLogEntry | Shape of one bridge traffic log entry (useExtensionEmbed({ debug: true })) |
| BridgeDebugLogPanel | Opt-in component — live view of bridge send/recv traffic, latency, ok/error |
| BridgeTokenPreviewPanel | Opt-in component — swatch preview of the UITokens actually sent to the extension |
| BridgeHostDebugKit | Unified dev-only debug UI: log + token preview + scope revoke + token toggle (D-05/D-06) |
Widgets sub-package (@finamx/host-bridge/widgets)
DivKit + custom-id widget host helpers for routing CMS-authored widgets (D-07).
Requires optional peer @divkitframework/react for DivKitWidgetHost rendering.
DivKit custom components — shadow DOM workaround
DivKit renders type: "custom" elements by inserting a <template shadowrootmode="open"> into the DOM via JavaScript (insertBefore). Browsers only process declarative shadow DOM during HTML parsing — dynamic JS insertion is silently ignored. The SVG/HTML inside the template ends up in an inert #document-fragment and never renders.
DivKitWidgetHost and WidgetBySlugEmbed handle this automatically: after DivKit mounts, they scan for <template shadowrootmode> elements, call host.attachShadow() imperatively, and move the template content into the real shadow root. A MutationObserver repeats this for any subsequent DivKit DOM updates.
Known limitation — reactive custom_props
The workaround removes the <template> from the DOM after processing (tmpl.remove()). DivKit still holds a reference to that detached element. If DivKit reactively updates custom_props (e.g. the CMS pushes new chartPoints via a live binding), it writes new SVG content to r.content.innerHTML on the detached template — the shadow root is not updated and shows stale content.
Current mitigations:
- All existing custom components (
finamSparkLine,finamNewsCard,webEmbed) receive staticcustom_propsfrom the CMS — no live bindings, so the limitation is not triggered.
Required fix when reactive custom components are added:
Instead of removing the template, keep it in the DOM and observe it with a second MutationObserver targeting the template's content. On each change, replace the shadow root's content:
// pseudo-code — implement in activateShadowRoots() in each DivKit host component
const shadow = host.attachShadow({ mode });
const sync = () => {
shadow.replaceChildren(document.importNode(tmpl.content, true));
};
sync();
// Do NOT remove the template — keep it for DivKit to update
const contentObserver = new MutationObserver(sync);
contentObserver.observe(tmpl.content, { subtree: true, childList: true, characterData: true });This change is required before shipping any DivKit widget whose custom_props change at runtime.
import {
resolveWidgetRenderMode,
fetchDivKitWidget,
DivKitWidgetHost,
CustomIdEmbedHost,
} from '@finamx/host-bridge/widgets';
// Route a board card to the correct renderer
const mode = resolveWidgetRenderMode(card.meta);
// → 'divkit' | 'extension-embed' | 'external-embed' | 'custom-id'
// Fetch DivKit JSON from CMS (host passes its own auth headers — T-48-05)
const widget = await fetchDivKitWidget('my-slug', {
platformBase: 'http://localhost:3001',
headers: { 'X-User-Id': 'poc-user-1' },
});
// All-in-one routing component (render props per path)
<CustomIdEmbedHost
meta={card.meta}
renderDivKit={(slug) => (
<DivKitWidgetHost
slug={slug}
fetchOptions={{ platformBase: 'http://localhost:3001', headers: { 'X-User-Id': userId } }}
/>
)}
renderExtension={(extensionId) => <ExtensionEmbed extensionId={extensionId} />}
renderExternalEmbed={(slug) => <ExternalEmbedContent slug={slug} />}
/>| Export | Description |
|--------|-------------|
| resolveWidgetRenderMode(meta) | Returns 'divkit' | 'extension-embed' | 'external-embed' | 'custom-id' from card metadata |
| fetchDivKitWidget(slug, options) | GET /api/v1/divkit/widgets/:slug — host passes platformBase + auth headers |
| DivKitWidgetResponse | Response type for fetchDivKitWidget |
| DivKitWidgetHost | React component — fetches + renders DivKit JSON; dynamic import of @divkitframework/react |
| CustomIdEmbedHost | Routes card metadata to render props for divkit/extension/external paths |
MCP sub-package (@finamx/host-bridge/mcp)
| Export | Description |
|--------|-------------|
| createMcpWidgetTools(options) | Creates plain-object MCP tool definitions for widget rendering |
| renderWidgetForAgUi(options) | Builds an ag-ui widget_render event payload |
| createWidgetAdapter(options) | Generic launch→render adapter for non-MCP systems |
| McpToolDefinition | Plain-object MCP tool type (no @modelcontextprotocol/sdk hard dep) |
| AgUiWidgetEvent | ag-ui widget render event type |
Build
npm run build # tsc → dist/ (includes dist/mcp/)
npm test # build + run capability-router testsOutput is ESM-only ("type": "module"). Part of the FinamX Mobile monorepo.
Bootstrapping a brand-new host app (Claude Code skill)
Everything in this README is also packaged as a global Claude Code skill —
finamx-extension-host — so a new project doesn't need a human to manually replay these
steps. It's installed at ~/.claude/skills/finamx-extension-host/SKILL.md (global, not tied
to this repo) and covers: installing @finamx/bridge-protocol + @finamx/host-bridge from
npm, fetching a launch, rendering via the hook or ExtensionEmbedFrame, wiring real design
tokens from the host's own theme, opt-in debug tooling, and capability proxying for sensitive
scopes (e.g. portfolio.read).
To run it, in any project, from a Claude Code session:
/finamx-extension-hostor just describe the goal in plain language (e.g. "wire up extension hosting in this app") —
Claude Code matches the skill automatically since it's registered globally. The skill's first
step always re-checks npm view @finamx/host-bridge version before doing anything, since this
package's publish status can change between sessions.
If you're not using Claude Code, follow the "Quick Start" sections above in order: Install → launch → render (hook or component) → design tokens → debug tooling → capability proxying — that's the same sequence the skill automates.
For a full walkthrough that also covers starting the platform locally, picking a seeded test
extension, and a concrete verification checklist, see
docs/guides/connect-a-new-host.md.
