Getting started
Bootstrap a portal block: connect to the portal, gate your app on the session, and size the iframe.
This page walks through standing up a real block, the way a HEMS built with Spark does it. The shape is always: connect once → expose the session to your tree → keep the iframe sized to your content.
1. Connect to the portal
Everything starts with initialize(). It performs the
postMessage handshake with the parent portal and resolves to a
SparkSession. It's safe to call from anywhere; repeat
calls return the same cached session.
import { initialize } from '@epilot/spark-sdk'
const session = await initialize()initialize() rejects if the portal doesn't answer within the timeout
(default 5s), so always handle the failure. That's your "couldn't connect"
state.
2. Gate your app on the session
In React, do the handshake once in a provider, render a spinner while it's in flight, an error state if it fails, and the app once it's ready. Downstream components read the session from context.
Here is a HEMS's spark-session-context.ts, a tiny context plus a hook
that throws if used outside the provider:
// app/providers/spark-session-context.ts
import type { SparkSession } from '@epilot/spark-sdk'
import { createContext, useContext } from 'react'
interface SparkSessionContextValue {
session: SparkSession
}
export const SparkSessionContext =
createContext<SparkSessionContextValue | null>(null)
export function useSparkSession(): SparkSession {
const context = useContext(SparkSessionContext)
if (!context) {
throw new Error('useSparkSession must be used inside SparkSessionProvider')
}
return context.session
}And the provider that drives the connection lifecycle (simplified from
spark-session-provider.tsx; a full implementation also coordinates view
transitions):
// app/providers/spark-session-provider.tsx
import { initialize, updateContentHeight } from '@epilot/spark-sdk'
import type { SparkSession } from '@epilot/spark-sdk'
import { Callout, CalloutDescription } from '@epilot/spark-ui/callout'
import { Spinner } from '@epilot/spark-ui/spinner'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { SparkSessionContext } from './spark-session-context'
type SessionStatus =
| { kind: 'loading' }
| { kind: 'ready'; session: SparkSession }
| { kind: 'error' }
export function SparkSessionProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement | null>(null)
const [status, setStatus] = useState<SessionStatus>({ kind: 'loading' })
useEffect(() => {
let observer: ResizeObserver | null = null
void (async () => {
try {
const session = await initialize()
setStatus({ kind: 'ready', session })
// Keep the iframe sized to the content (see step 3)
const container = containerRef.current
if (container) {
observer = new ResizeObserver(() =>
updateContentHeight(container.scrollHeight),
)
observer.observe(container)
}
} catch {
setStatus({ kind: 'error' })
}
})()
return () => observer?.disconnect()
}, [])
return (
<div ref={containerRef} className="flex flex-col gap-4 pb-6">
{status.kind === 'loading' && (
<div className="flex flex-col items-center justify-center gap-3 py-12">
<Spinner />
<p className="text-sm text-gray-light">{t('session.connecting')}</p>
</div>
)}
{status.kind === 'error' && (
<Callout variant="error">
<CalloutDescription>
{t('session.connectionError')}
</CalloutDescription>
</Callout>
)}
{status.kind === 'ready' && (
<SparkSessionContext.Provider value={{ session: status.session }}>
{children}
</SparkSessionContext.Provider>
)}
</div>
)
}With that in place, any component can reach the session:
function ContractTitle({ id }: { id: string }) {
const session = useSparkSession() // typed SparkSession, never null
// ...
}3. Report your content size
The portal can't measure the DOM inside your cross-origin iframe, so you tell
it how tall the block is and it resizes the iframe to match. Call
updateContentHeight (or
updateContentSize for width + height)
whenever your layout changes.
The robust pattern is a ResizeObserver on your root container, which catches
data loading, route changes, expanding panels, everything:
import { updateContentHeight } from '@epilot/spark-sdk'
const observer = new ResizeObserver(() => {
updateContentHeight(container.scrollHeight)
})
observer.observe(container)If your block has absolutely-positioned or overflowing elements (footers, popovers anchored to the document), report both dimensions with
updateContentSize({ width: el.scrollWidth, height: el.scrollHeight }).
4. Read the block's config
The portal passes per-installation configuration in
session.config. A HEMS reads
its Integration Hub id and target entity from there and exposes them via a small
context:
// app/providers/block-context-provider.tsx
const session = useSparkSession()
const integrationId = session.config?.integration_id as string | undefined
const entityIds = (session.config?.entityIds ?? {}) as Record<string, string>
const assetId = entityIds.asset
const isMockedMode = session.config?.is_mocked_mode === trueintegrationId is what every use-case call needs, and
isMockedMode lets the block render mock data in the block editor without hitting
real integrations.
5. Sync language (optional but recommended)
Drive your i18n library from session.lang and subscribe to
onLocaleUpdate so changing the portal language updates
the block live, without an iframe reload:
// app/providers/i18n-provider.tsx
import { onLocaleUpdate } from '@epilot/spark-sdk'
useEffect(() => {
void i18n.changeLanguage(session.lang ?? DEFAULT_LANGUAGE)
}, [session.lang])
useEffect(() => {
return onLocaleUpdate((lang) => {
void i18n.changeLanguage(lang)
})
}, [])