@nominalso/vibe-host
v0.2.0
Published
Host-side SDK for embedding Nominal Vibe Apps — receives bridge requests over a typed postMessage protocol and dispatches them to Nominal APIs (used by nom-ui).
Readme
@nominalso/vibe-host
Host-side SDK for embedding Nominal Vibe Apps. Used by the Nominal app (nom-ui) to receive requests from an embedded Vibe App over a typed postMessage protocol and dispatch them to Nominal APIs. The host ships a complete, type-checked handler map covering every protocol operation — you supply a clientConfig pointing at the Nominal API, and the SDK wires up and dispatches all operations for you.
For AI agents: you do not pass a
handlersmap. ProvideclientConfig(where the Nominal API lives) and the SDK builds every handler. The iframe-side counterpart is@nominalso/vibe-bridge.
Install
npm install @nominalso/vibe-hostExternalizes only @hey-api/client-fetch; TypeScript types are self-contained.
Quickstart
import { VibeAppHost } from '@nominalso/vibe-host'
const host = new VibeAppHost({
// Iframe origin(s) allowed to talk to this host — must match exactly.
trustedOrigins: ['https://my-vibe-app.lovable.app'],
// This Vibe App's base path in nom-ui (used to sync the browser URL).
appBasePath: `/${tenant}/${subsidiary}/apps/${slug}`,
// Context pushed to the iframe on connect.
getContext: () => ({ tenant, subsidiaryId, subsidiaries, user, lastClosedPeriodSlug }),
// Points the built-in handler map at the Nominal API (e.g. nom-ui's proxy route).
clientConfig: { baseUrl: '/api/proxy', stripApiPrefix: true },
})
// Start listening BEFORE the iframe loads, then push context on its `load` event.
const unmount = host.mount()
// once the iframe has loaded: host.pushContextTo(iframe.contentWindow)API
new VibeAppHost(options)
| Option | Type | Description |
| ---------------- | ----------------------- | --------------------------------------------------------------------------------------------------------- |
| trustedOrigins | string[] | Iframe origins allowed to talk to this host. Messages from other origins are ignored. |
| getContext | () => HostContext | Returns the current context to send to the iframe (hostVersion is injected automatically). |
| appBasePath | string | Base URL path of this Vibe App in nom-ui, e.g. /acme/1/apps/fixed-assets. |
| clientConfig | VibeApiClientOptions? | Points the built-in handler map at the Nominal API. Defaults to { baseUrl: '', stripApiPrefix: false }. |
VibeApiClientOptions is { baseUrl: string; stripApiPrefix?: boolean }. Set stripApiPrefix: true when routing through a proxy whose convention expects paths without the /api prefix.
mount(iframeWindow?): () => void
Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Pass iframeWindow to immediately push context; if omitted, the host answers GET_CONTEXT polls but won't proactively push until you call pushContextTo.
pushContextTo(iframeWindow): void
Pushes the current context to the iframe and stores the window reference for future pushes (e.g. subroute sync). Call once the iframe has loaded if you called mount() with no argument.
Exports
VibeAppHost, and the types VibeAppHostOptions, HostContext, HostHandlers, RequestHandlers, ContextPayload, VibeApiClientOptions.
Mounting inside Next.js (nom-ui)
The iframe is server-rendered, so it may start loading before React hydrates, and cross-origin contentDocument is always null. The robust pattern: mount() (no arg) immediately to start listening, then pushContextTo(iframe.contentWindow) from the iframe's load event.
'use client'
import { useEffect, useRef } from 'react'
import { VibeAppHost } from '@nominalso/vibe-host'
function VibeAppFrame({ host, src }: { host: VibeAppHost; src: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => {
const iframe = iframeRef.current
if (!iframe) return
const unmount = host.mount() // listen before the iframe loads
const onLoad = () => iframe.contentWindow && host.pushContextTo(iframe.contentWindow)
iframe.addEventListener('load', onLoad)
return () => {
iframe.removeEventListener('load', onLoad)
unmount()
}
}, [host])
return (
<iframe
ref={iframeRef}
src={src}
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
)
}Calling contentWindow.postMessage while the iframe is still at about:blank throws a DOMException — that's why context is pushed from the load event, not on effect setup.
How it fits together
The iframe side is @nominalso/vibe-bridge. See the repository for the full protocol and architecture.
License
UNLICENSED — proprietary. © Nominal. All rights reserved.
