@hyperfrontend/featuresHow to compose independently shipped features on one page
Put N independently built, independently deployed apps on one page, have them coordinate through you, and keep the page running when any one of them dies. No merged codebases, no shared bundle.
You embed each app through its own shell package, generated by @hyperfrontend/features from the contract that app declares; inside, each app runs createFeature from the same SDK. The snippets come from the koi pond, which composes eight apps in eight frameworks this way.
1. Open one channel per feature
Hold one shell factory per feature. Each generated shell bakes its own feature's contract, URL, display modes, and protocol pin, so you install N typed commitments rather than N strings.
/** One factory per koi, each from its own vendored shell package. */
const SHELL_FACTORIES: Record<KoiFramework, KoiShellFactory> = {
vanilla: (options) => createVanillaKoiShell(options),
react: (options) => createReactKoiShell(options),
vue: (options) => createVueKoiShell(options),
svelte: (options) => createSvelteKoiShell(options),
solid: (options) => createSolidKoiShell(options),
preact: (options) => createPreactKoiShell(options),
lit: (options) => createLitKoiShell(options),
angular: (options) => createAngularKoiShell(options),
}
e.g. apps/demos/koi-pond/host/src/scene/koi-sessions.ts#L72-L82
2. Give interoperating features one contract
A feature already owns its contract: feature.config.ts names the file and the glue module hf init writes imports the same one, so the shell and the running app are packed from a single authored source. Where several features exchange the same actions, publish that contract for each of them to install and point each config at a local module re-exporting it.
/**
* Shell-packaging entry for the "@hyperfrontend/demo-koi-fish-vanilla" feature's contract.
*
* The koi contract's single source of truth lives in the shared koi library;
* this file re-exports it so the shell build bakes the very object the running
* app wires in `src/hyperfrontend.feature.ts` — the generated package and the
* running feature can never disagree about the wire.
*/
import { koiFishContract } from '@hyperfrontend/demo-koi-lib/contract'
export default koiFishContract
e.g. apps/demos/koi-pond/fish-vanilla/koi-fish.contract.ts#L2-L12
A contract that declares a version must match its config's or the build refuses, and the handshake then gates each pairing on caret compatibility, so independently deployed apps announce their skew instead of misreading each other. Validation costs per message, so leave your highest-cadence actions without a payload schema and keep one on the rest.
3. Mount every feature into a layer you own
One absolutely positioned container per feature, each session mounted in embedded mode. The host is the single authority on geometry, so your layout lives in those containers.
export function openShoal(layers: ReadonlyMap<KoiFramework, HTMLElement>): KoiSession[] {
const sessions: KoiSession[] = []
for (const framework of KOI_FRAMEWORKS) {
const layer = layers.get(framework)
if (layer === undefined) {
continue
}
const shell = SHELL_FACTORIES[framework]({
container: layer,
// why: Eight handshakes queue behind one another on a cold load, and the ten-second default times the last of them out.
openTimeoutMs: OPEN_TIMEOUT_MS,
...(COMPOSED_DEPLOYMENT && { url: fishHomeUrl(framework) }),
})
sessions.push({ framework, layer, shell })
}
return sessions
}
e.g. apps/demos/koi-pond/host/src/scene/koi-sessions.ts#L131-L147
Budget openTimeoutMs for N queued handshakes rather than one. Override a shell's baked url while one origin serves everything from sub-paths, and drop the override once features get their own origins.
4. Coordinate through the host
The host is the only party that can see every feature, so coordination lives there. Have features report their own state up on a cadence, aggregate it, and send each feature the filtered view it needs.
if (elapsedMs - lastRelayAt >= RELAY_INTERVAL_MS) {
lastRelayAt = elapsedMs
for (const session of sessions) {
session.shell.send('neighbors', relay.neighborsFor(session.framework, pond, now))
}
}
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L546-L551
On the receiving side of a schema-less hot path, narrow the payload yourself.
link.on('neighbors', (data) => {
// why: `neighbors` is schema-less so the SDK never validated it; a malformed relay must thin the shoal, never crash the frame.
const entries = Array.isArray(data) ? data : []
const observed: NeighborObservation[] = []
for (const entry of entries) {
const neighbor = readNeighbor(entry)
if (neighbor !== null) {
observed.push(neighbor)
}
}
koi.observe(observed)
})
e.g. apps/demos/koi-pond/fish-vanilla/src/feature/wire-contract.ts#L116-L127
5. Choose security per boundary
Spend protocol v1, the per-message security envelope, on boundaries that cross trust: separately deployed sites, product meaning in every message.
/** The @hyperfrontend/demo-koi-pond feature handle; use it to send and receive contract actions. */
export const feature = createFeature({
name: '@hyperfrontend/demo-koi-pond',
contract,
protocol: 'v1',
})
e.g. apps/demos/koi-pond/host/src/hyperfrontend.feature.ts#L41-L46
Keep it off high-cadence channels. Under v1 every enveloped message pays a fresh key derivation, and many concurrent chatty channels collapse, dropping messages silently instead of erroring. Where that is your traffic, declare the protocol away and pack the shell with hf build --ci --allow-open, which keeps an open channel a decision someone acknowledged rather than a default.
import { defineConfig } from '@hyperfrontend/features'
export default defineConfig({
name: '@hyperfrontend/demo-koi-fish-vanilla',
// note: The version tracks the shared koi contract's version; the shell build requires the two to agree.
version: '0.7.0',
contract: './koi-fish.contract.ts',
url: 'https://demo-koi-fish-vanilla-production.up.railway.app/',
// why: An open shell, acknowledged at pack time - eight koi share one page reporting outlines at high cadence, and a per-message security envelope across eight channels collapses delivery. Messages still pin to the configured origin.
protocol: 'none',
display: {
// note: Embedded is the koi's only presentation - a host-owned transparent layer the pond composites into its scene.
modes: ['embedded'],
},
})
e.g. apps/demos/koi-pond/fish-vanilla/feature.config.ts#L2-L16
An open channel still pins messages to the configured origin and still runs as a separate document. Compare the pin on both sides at build time: a counterpart that omits the protocol downgrades the session to plaintext, and no runtime signal reports it.
6. Allow the whole ancestor chain
frame-ancestors is checked against every ancestor, so a policy naming only the immediate parent blanks the frame one level further out. Set it before you nest any of this, e.g. apps/demos/koi-pond/README.md.
7. Survive any single feature dying
Scope every reaction to the one feature. Independent sessions share no bundle, no state, and no channel, so one feature's failure domain is one layer of the page.
shell.on('close', () => {
opened = Math.max(0, opened - 1)
relay.forget(framework)
inspected.delete(framework)
chrome.release(framework)
if (drag?.framework === framework) {
drag = null
refreshCursor()
}
roster.setConnected(framework, false)
hooks.onShoal(opened, sessions.length)
if (hovered === framework) {
setHover(null)
}
})
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L321-L335
Retry error with reason: 'open-timeout', and only that one: a session that opened and merely went quiet is still someone's live screen.
shell.on('error', (data: unknown) => {
// why: A koi that never answers must not hold the pond dark behind a curtain waiting for it.
setCurtain(stage, true)
// why: A timed-out handshake leaves a destroyed mount and the SDK never retries — on a slow device the eight heavy apps race one deadline, and without this a loser is simply a fish that never existed. Only the timeout is retried; an unresponsive session is still alive and must not be torn down under its visitor.
if ((<{ reason?: string }>data)?.reason === 'open-timeout' && (retries.get(framework) ?? 0) < OPEN_RETRIES) {
retries.set(framework, (retries.get(framework) ?? 0) + 1)
window.setTimeout(() => {
shell.open()
}, OPEN_RETRY_DELAY_MS)
}
})
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L339-L349
Keep the composed page behind a cover until every session opens, and lift it on a deadline regardless, so one unreachable app cannot hold the page dark.
Check it worked
Load your page and confirm every session opens and reports its connection state. Then kill one: block every request to a single feature's origin in your browser devtools and reload. The cover lifts on its deadline, the remaining features run, the blocked one reports disconnected, and the page still takes input.