@nominalso/vibe-host
v0.12.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).
Downloads
1,821
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. Exact origins, or glob
// patterns (e.g. 'https://*.vercel.app') — add patterns only in non-prod.
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 },
// The iframe this host owns. Optional, but wire it whenever you have the
// element — see "Hosts that outlive, or share a page with, an iframe".
getIframe: () => iframeRef.current,
})
// Start listening. The iframe identifies itself via its CONNECT handshake, and
// the host replies with its context — no iframe window needed up front.
const unmount = host.mount()API
new VibeAppHost(options)
| Option | Type | Description |
| ---------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| trustedOrigins | string[] | Iframe origins allowed to talk to this host — exact origins or glob patterns (https://*.vercel.app, honoured unconditionally, so add only in non-prod). 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 }. |
| getIframe | () => HTMLIFrameElement \| null? | The <iframe> this host owns, read live. Lets the host recognise its own frame without a handshake — see below. |
VibeApiClientOptions is { baseUrl: string; stripApiPrefix?: boolean }. Set stripApiPrefix: true when routing through a proxy whose convention expects paths without the /api prefix.
Testing preview deployments with trustedOrigins patterns
A pattern entry's * matches exactly one DNS label or port — anchored, scheme literal — so https://*.vercel.app matches https://pr-7.vercel.app but not https://a.b.vercel.app or https://pr-7.vercel.app.evil.com. Patterns are honoured unconditionally, so gate the list on your own deploy environment — keep production exact-only:
const trustedOrigins =
process.env.NOM_ENV === 'production'
? ['https://app.nominal.so']
: ['https://app.nominal.so', 'https://*.vercel.app', 'https://*.lovable.app']Because you cannot postMessage to a pattern, proactive pushes target the exact allowlist entries plus the connected iframe's learned origin; a pattern-matched iframe is reached via the concrete origin learned from its CONNECT handshake.
Hosts that outlive, or share a page with, an iframe — getIframe
CONNECT is sent once per iframe document, and the bridge memoises the context it receives. That single handshake is all the host has to identify the app, which breaks in two ways getIframe closes:
- The iframe outlives the host. React
<Activity mode="hidden">and Next.js's route bfcache run effect cleanups while keeping the DOM. A host built in an effect is therefore rebuilt around a still-running iframe that will never re-CONNECT, so every op it sends comes backNOT_CONNECTED— silently, since nothing failed to load. WithgetIframewired,mount()adopts the live frame and the app resumes with its state intact. - Two hosts on one page. Every host listens on the same
window, so one app'sCONNECTreaches all of them, and a glob allowlist entry can't tell two preview apps apart. WithoutgetIframethe last app to connect owns every host's connection and the others are rejected for good; with it, each host only ever talks to its own frame.
Reading the element at mount is safe because contentWindow is the frame's WindowProxy, whose identity survives navigation and reload of that frame — only the inner Window is swapped, so it still matches the event.source of everything that frame sends.
Adoption sends nothing. If a resumed app should also see the current context, call refreshContext() after mount().
mount(): () => void
Starts listening for bridge messages and sets up browser back/forward sync. Returns a cleanup function. Takes no iframe window — the iframe identifies itself via its CONNECT handshake (and re-identifies on every reload), and the host replies with its context. With getIframe wired it also adopts that frame up front, so one that outlived a previous host stays connected.
refreshContext(): void
Re-reads getContext() and pushes it to the connected iframe (e.g. after a tenant/subsidiary switch, or on resume), so the app reacts via bridge.onContextChange. No-op until the host knows its iframe.
notifyAuthChange(auth): void
Pushes a Nominal auth change (logout, or identity/tenant switch) to the connected iframe, so the app reacts via bridge.onAuthChange. No-op until the host knows its iframe.
Exports
VibeAppHost, and the types VibeAppHostOptions, HostContext, HostHandlers, RequestHandlers, ContextPayload, AuthPayload, VibeApiClientOptions.
Mounting inside Next.js (nom-ui)
Just mount() in an effect — no iframe-window handling, no load-event dance. The iframe is server-rendered and may load before React hydrates, but that's fine: its bridge re-sends CONNECT until the host (now listening) answers, so there's no race.
Do pass getIframe, though: effect cleanups run whenever the tree is hidden by <Activity> or kept in the route bfcache, and without it the rebuilt host cannot recognise the iframe that survived (see getIframe).
'use client'
import { useEffect, useRef, useState } from 'react'
import { VibeAppHost } from '@nominalso/vibe-host'
function VibeAppFrame({ src }: { src: string }) {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [host] = useState(() => new VibeAppHost({ ...options, getIframe: () => iframeRef.current }))
useEffect(() => host.mount(), [host]) // returns the unmount cleanup
return (
<iframe
ref={iframeRef}
src={src}
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
)
}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.
