@hyperfrontend/features/hostHost
Host-side SDK for embedding hyperfrontend features — shell factory, display modes, iframe utilities, and lifecycle.
import { builtInDisplayModes, createShell, DisplayMode } from '@hyperfrontend/features/host'
const shell = createShell({
modes: builtInDisplayModes,
url: 'https://features.example.com/clock',
container: '#clock-slot',
displayMode: DisplayMode.Embedded,
})
shell.on('open', () => console.log('feature connected'))
shell.on('tick', (time) => console.log('feature said', time))
shell.open()
shell.send('set-timezone', { tz: 'UTC' })
API
| Export | Purpose |
|---|---|
createShell | Build a shell handle from an explicit modes map — only the mounts you pass ship. |
builtInDisplayModes | The all-modes map, for hosts that want every mode available. |
mountEmbedded … | The four mount functions (mountEmbedded, mountDialog, mountPopup, mountStandalone) for the modes map. |
DisplayMode | The four built-in modes: Embedded, Dialog, Popup, Standalone. |
ShellHandle | Type of the handle returned by createShell. |
CreateShellOptions | Options accepted by createShell (ShellOptions plus the modes map). |
ExperiencePlugin | Opt-in extension point for layering transitions/animations onto display modes. |
The modes map is how unused mode code stays out of bundles: a generated shell passes exactly the modes its feature declared, and a hand-written host that only ever embeds can pass { embedded: mountEmbedded } and ship one mode. Opening a mode outside the map throws, naming the supported set.
The shell wraps a @hyperfrontend/nexus broker: send emits a contract action to the feature, and on subscribes to feature messages and the open/closing/close/error/status/dirty-state/dismiss lifecycle events. close disconnects the channel politely (the feature gets a closing flush window, and isDirty reports declared unsaved work first); destroy also releases the DOM.
Opening is asynchronous: isOpen stays false and sends queue until the wire handshake with the feature completes, flushing on the open event. If the feature never completes the handshake within openTimeoutMs (default 10 s), the shell tears the mount down and emits error with reason: 'open-timeout'.
A feature that reloads itself (a refresh, an in-frame navigation, a dev-server rebuild) ends its session but keeps its mount: close fires with { reason: 'peer-reload' }, then open fires again once the new document completes its own handshake, and the shell re-announces the presentation to it. Treat the pair as a session boundary — pending requests reject, isDirty resets, and anything you sent the previous document needs sending again. To refuse the reload instead, destroy() on that reason.
Display modes and sizing
The host owns presentation. It picks the display mode (from the set the feature's contract declares), announces it to the feature once per mount — the announcement already carries the frame's initial dimensions, so the feature lays itself out without waiting for a second message — and is the single authority on frame geometry: every dimension crosses the boundary as an exact pixel value, host to feature, never the other way.
Mounted is not displayed: the frame mounts hidden and is revealed only once the session opens, so the user never sees (or clicks into) a frame whose feature is not ready. A dialog pane cannot intercept the page while it is still connecting; an embedded frame reserves its box without painting.
Embedded mounts the frame inline in your container and the frame fills the container's content box — measured before the frame is inserted, then observed with a ResizeObserver; every later change is reported to the feature. While the container has no measurable size (hidden, not yet laid out, or nothing gives it a height), the SDK applies a dynamic viewport-derived fallback so the embed is never invisible by accident; the fallback retires as soon as your layout takes over. A feature with intrinsic dimensions can bake fixed embedWidth/embedHeight instead (or you can pass them) — then the frame gets exactly those pixels and you place the container somewhere they fit; the SDK never distorts a fixed agreement.
Dialog is a full-viewport transparent pane layered above your page. The feature draws its dialog box (and any backdrop paint) inside the pane; dialogWidth/dialogHeight set the inner box (viewport-derived when unset) and dialogPosition places it — center by default, or any edge/corner (top-left … bottom-right). The feature detects backdrop clicks and in-frame Escape presses and reports them as a dismiss signal; dialogBackdrop decides what the shell does — close (default) starts the polite teardown, event emits a dismiss event for you to handle, none ignores it. closeOnEscape covers Escape from both documents.
shell.open({ displayMode: DisplayMode.Dialog, dialogWidth: 480, dialogPosition: 'top-center', dialogBackdrop: 'event' })
shell.on('dismiss', ({ source }) => console.log('backdrop interaction', source))
Popup opens a separate window at popupWidth/popupHeight (viewport-derived when unset), placed on the screen per popupPosition (center by default, or any edge/corner). After that the window belongs to the browser and the user: they move and resize it freely, and no frame geometry crosses the boundary. The window's title and chrome are not the host's to set — the title comes from the loaded document (the feature sets its own document.title), and browsers ignore chrome flags like resizability for window.open. Standalone is a plain new tab — the simplest mode, deliberately free of presentation coordination.
Transparency is on by default in both iframe modes (allowtransparency plus a matched color-scheme pin on both sides — a mismatch would force an opaque canvas), so features can render non-rectangular designs, blend with your UI, and paint dialog backdrops. Want an opaque embed? Give your container a background.
What the SDK deliberately does not do
- Content-driven growth (the embed grows with its content) is not built in — it would hand geometry authority to the feature, which is the inversion this design exists to avoid. The recipe is one contract action: have the feature emit its content height as product data, and set it on the container you own (
shell.on('contentHeight', (px) => setContainerHeight(px))); the SDK's container observation propagates the change back down. - Clipping and scrolling are not managed. The feature owns its document's overflow behaviour; the host owns the container's. Neither needs a protocol.
- Draggable/resizable dialog boxes are not built in — but the full-viewport pane makes them a pure feature-side concern: the pane never moves, so the feature can drag or resize its inner box with ordinary CSS/pointer code and nothing needs to cross the boundary. If your host UI needs to know, carry position as an ordinary contract action.
Browser capabilities
Capability is one of the two axes in the Security Model — read it for where these options sit relative to what the browser enforces and what stays the operator's job.
Two ShellOptions fields govern what the feature frame may do with the browser around it, both applied before the frame loads (the only moment they take effect) and both scoped to the iframe modes — popup and standalone open top-level windows, which ask the user for permissions directly.
permissions delegates Permissions-Policy features (camera, fullscreen, clipboard, …) to the frame via the iframe allow attribute. Browsers deny these to cross-origin frames by default, so a feature that needs one only works when the host delegates it. A generated shell bakes the needs the feature declared at build time (also disclosed in its README and metadata.json); a host-supplied list replaces the baked one entirely.
sandbox is the host's containment lever and is never baked by a build. true (or an opt-in object) starts the frame from the browser's deny-all sandbox; the SDK manages the two hazardous tokens itself — allow-scripts is always granted (the feature runtime is JavaScript), and allow-same-origin is granted only to cross-origin feature URLs, since a same-origin frame holding both tokens could remove its own sandbox. A sandboxed same-origin feature therefore runs with an opaque origin (no cookies or storage) while the messaging protocol still connects. Everything else — forms, popups, modals, downloads, topNavigationByUserActivation — is denied unless opted in. Requesting a sandbox on popup or standalone throws, because no containment can apply to a top-level window.
shell.open({
permissions: ['fullscreen', 'clipboard-write'],
sandbox: { downloads: true },
})API Reference
ƒ Functions
Provisions a nexus broker and returns a handle whose
open mounts the feature in the requested display mode. The shell is built from an explicit modes map — pass the mounts this host supports (which is how generated shells exclude undeclared modes from their bundles) or builtInDisplayModes for all of them; opening a mode outside the map throws, naming the supported set. The contract option takes the feature's contract exactly as the feature authored it; the shell derives the host-side orientation itself, so the handle sends what the feature accepts and receives what the feature emits.Parameters
| Name | Type | Description |
|---|---|---|
§options | CreateShellOptions | Create-time options including the modes map; overridable per open call. |
Returns
ShellHandleopen, close, destroy, send, on, and isOpen.Example
Embedding a clock feature with every built-in mode available
const clock = createShell({ modes: builtInDisplayModes, container: '#clock', url: 'https://clock.example.com' })
clock.open({ displayMode: DisplayMode.Dialog, dialogWidth: 530 })
clock.on('timeUpdated', (data) => console.log(data))◈ Interfaces
Properties
closeOnEscape?:booleantrue. Enforced on both sides of the boundary: the host listens in its own document, and the feature reports an Escape pressed inside its frame as a dismiss signal the host acts on (dialog mode only).container?:string | HTMLElementcontract?:FeatureContractemitted = what the feature sends, accepted = what the feature handles). The shell derives the host-side orientation itself — hand it the feature's contract, never a pre-swapped copy. Replaces the generic default when provided.dialogBackdrop?:BackdropBehaviorclose. See BackdropBehavior.dialogHeight?:numberdialogPosition?:BoxPositioncenter.dialogWidth?:numberembedWidth?:numberembedWidth and embedHeight are set, the embedded iframe receives exactly those dimensions instead of filling its container, and the host application is responsible for placing the container somewhere the feature fits — the SDK never distorts or reinterprets fixed dimensions. Setting only one of the pair throws.onUnresponsive?:UnresponsivePolicyemit.openTimeoutMs?:numbererror with reason: 'open-timeout' and tearing the mount down; defaults to 10000. Opening is asynchronous:
isOpen stays false and the open event fires only once the wire handshake completes. send/request calls issued in between queue on the channel and flush on open.permissions?:unknownallow attribute scoped to the frame's own origin. Shell builds bake the feature's declared needs here; a host-supplied list replaces the baked one entirely. Only the iframe modes (embedded, dialog) apply it — popup and standalone open top-level windows, which request these permissions from the user directly.plugins?:unknownonMount runs in registration order, onUnmount in reverse.popupHeight?:numberpopupPosition?:BoxPositioncenter.popupWidth?:numbersandbox?:boolean | SandboxOptionstrue (or an opt-in object) starts the frame from the browser's deny-all sandbox; the SDK always returns allow-scripts, grants allow-same-origin only to cross-origin feature URLs, and denies everything else unless opted in — see SandboxOptions for the managed tokens and why. Host-decreed and never baked by a shell build. Only meaningful for the iframe modes: opening popup or standalone with a sandbox set throws, because no containment can apply to a top-level window.Properties
Properties
Properties
element?:HTMLElementpresent:PresentPayloadviewport?:ViewportReporterProperties
Created by a display-mode mount seeded with a synchronous initial measurement:
current() feeds the presentation announcement, and once the shell calls start, only changes relative to what was already announced are forwarded — the initial size never crosses twice.Properties
Structurally compatible with nexus's channel contract action shape so the same contract can drive both messaging and the shell type generator.
Properties
required?:booleanaccepted entries. Unflagged actions never gate the connection, so additive contract evolution stays non-breaking.respondsWith?:stringRegister plugins through ShellOptions.plugins. After each successful mount the shell calls
onMount on every plugin in registration order; before each unmount it calls onUnmount one plugin at a time in reverse registration order, awaiting any returned promise, then runs the teardowns returned by onMount (also in reverse registration order) and finally removes the feature. The SDK ships no built-in plugins.Properties
Properties
element:HTMLElementembedded, the dialog container for dialog, and null for popup and standalone, which open a separate window with no in-document element.This is the same shape the on-disk
*.contract.json files and the shell generator consume.Properties
version?:stringdialog.Properties
viewport?:ViewportPayloadrequest.Properties
Enabling ShellOptions.sandbox starts the frame from the browser's deny-all sandbox and returns capabilities selectively. Two tokens are managed by the SDK and are not configurable:
allow-scripts is always present (the feature runtime is JavaScript, so a script-less frame can never connect), and allow-same-origin is granted only when the feature URL resolves to a different origin than the host page — a same-origin frame holding both tokens could remove its own sandbox, so that pairing cannot be expressed. A sandboxed same-origin feature therefore runs with an opaque origin (no cookies or storage); the messaging protocol still connects. Every opt-in below defaults to false (denied).Properties
Properties
closeOnEscape?:booleantrue. Enforced on both sides of the boundary: the host listens in its own document, and the feature reports an Escape pressed inside its frame as a dismiss signal the host acts on (dialog mode only).container?:string | HTMLElementcontract?:FeatureContractemitted = what the feature sends, accepted = what the feature handles). The shell derives the host-side orientation itself — hand it the feature's contract, never a pre-swapped copy. Replaces the generic default when provided.dialogBackdrop?:BackdropBehaviorclose. See BackdropBehavior.dialogHeight?:numberdialogPosition?:BoxPositioncenter.dialogWidth?:numberembedWidth?:numberembedWidth and embedHeight are set, the embedded iframe receives exactly those dimensions instead of filling its container, and the host application is responsible for placing the container somewhere the feature fits — the SDK never distorts or reinterprets fixed dimensions. Setting only one of the pair throws.onUnresponsive?:UnresponsivePolicyemit.openTimeoutMs?:numbererror with reason: 'open-timeout' and tearing the mount down; defaults to 10000. Opening is asynchronous:
isOpen stays false and the open event fires only once the wire handshake completes. send/request calls issued in between queue on the channel and flush on open.permissions?:unknownallow attribute scoped to the frame's own origin. Shell builds bake the feature's declared needs here; a host-supplied list replaces the baked one entirely. Only the iframe modes (embedded, dialog) apply it — popup and standalone open top-level windows, which request these permissions from the user directly.plugins?:unknownonMount runs in registration order, onUnmount in reverse.popupHeight?:numberpopupPosition?:BoxPositioncenter.popupWidth?:numbersandbox?:boolean | SandboxOptionstrue (or an opt-in object) starts the frame from the browser's deny-all sandbox; the SDK always returns allow-scripts, grants allow-same-origin only to cross-origin feature URLs, and denies everything else unless opted in — see SandboxOptions for the managed tokens and why. Host-decreed and never baked by a shell build. Only meaningful for the iframe modes: opening popup or standalone with a sandbox set throws, because no containment can apply to a top-level window.Properties
◆ Types
Only the mount functions handed in are reachable, so a shell that passes the modes its feature contract declares (as generated shells do) ships no code for the others. Pass builtInDisplayModes to support every mode.
type DisplayModeMap = Partial<Record<DisplayMode, DisplayModeMount>>type DisplayModeMount = (context: MountContext) => MountResulthealthy— beats are arriving within the expected budget.unobservable— the host page or the feature page is hidden, so browser
suspect— the pages are visible and the miss budget is exhausted; the
gone— the session is closed or destroyed (or not yet open).
type HeartbeatState = "healthy" | "unobservable" | "suspect" | "gone"close (the default) treats the interaction as a close request and starts the polite teardown; event surfaces it as a dismiss event for the host consumer to handle; none ignores it.type BackdropBehavior = "close" | "event" | "none"center (the default) centers on both axes; the compound values anchor to an edge or corner of the area.type BoxPosition = "center" | "top-left" | "top-center" | "top-right" | "center-left" | "center-right" | "bottom-left" | "bottom-center" | "bottom-right"type DismissSource = "backdrop" | "escape"type EventHandler = (data: unknown) => voidBrowsers deny powerful features (camera, fullscreen, clipboard, …) to cross-origin frames by default, so a feature that needs one only works when the host delegates it. The union lists the common names for editor completion; any string the browser understands is accepted.
type FeaturePermission = "accelerometer" | "autoplay" | "camera" | "clipboard-read" | "clipboard-write" | "display-capture" | "encrypted-media" | "fullscreen" | "gamepad" | "geolocation" | "gyroscope" | "magnetometer" | "microphone" | "midi" | "payment" | "picture-in-picture" | "publickey-credentials-get" | "screen-wake-lock" | "usb" | "web-share" | "xr-spatial-tracking" | string & { }type RequestHandler = (data: unknown) => unknownnone is the local default (opt-in security); production builds must pick v1 or v2.type SecurityProtocol = "none" | "v1" | "v2"emit (the default) emits an error; unmount also tears the feature down; a callback takes over handling entirely with the UnresponsiveInfo. The policy runs once per suspect episode: a recovering beat returns the feature to healthy and re-arms it. While either page is hidden the watchdog pauses instead (unobservable) — throttled timers make silence weak evidence.type UnresponsivePolicy = "emit" | "unmount" | (info: UnresponsiveInfo) => void● Variables
createShell composes a shell from this full map; generated shells import the individual mounts and compose only the modes their feature declared, so this map (and the modes it would drag in) stays out of their bundles.The pane is a single transparent iframe spanning the host viewport; the feature renders its dialog box inside it and the transparent remainder is the backdrop. It mounts hidden — inert to the user and the page — and is revealed once the session opens. Backdrop and in-frame Escape interactions are detected by the feature side and cross as dismiss signals the shell acts on per
dialogBackdrop/closeOnEscape; an Escape pressed while the host document holds focus is handled here directly.By default the iframe fills the container's content box — measured before the iframe is inserted, so the announcement carries the container's own dimensions — and a reporter forwards every later change (with viewport-derived fallback dimensions while the container has none). When the merged options agree a fixed
embedWidth/embedHeight, the iframe receives exactly those dimensions and the host application places the container so the feature fits. The frame mounts hidden and is revealed once the session opens.The window opens at the agreed
popupWidth/popupHeight (falling back to a viewport-derived size), placed on the screen per popupPosition — centered by default. Once open, the window is the browser's: the user may move and resize it freely, the feature's own window is its viewport (no viewport reports cross the boundary), and no sandbox or permissions delegation can apply to a top-level window. The window's title and chrome belong to the loaded document and the browser — the feature sets document.title; the host cannot.The simplest mode: the browser's normal new-tab behavior is sufficient, so no sizing or presentation coordination applies — only the ordinary session lifecycle over the opener relationship.
The host selects the mode; a feature declares which modes it supports in its
feature.config.* display.modes, and the generated shell composes exactly those.