How 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 stocks one scene from eight independently built framework apps 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 app, 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#L77-L87
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 openInstance(stage: PondStage, framework: KoiFramework, ordinal: number): KoiSession {
const id = koiInstanceId(framework, ordinal)
const layer = addLayer(stage, id, framework)
const shell = SHELL_FACTORIES[framework]({
container: layer,
// why: Several 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) }),
})
return { id, framework, ordinal, layer, shell }
}
e.g. apps/demos/koi-pond/host/src/scene/koi-sessions.ts#L143-L153
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.
Two sessions of one feature are ordinary, so mint an instance id per session and key everything by it: the layer, the roster entry, the fan-out, the retry. Keying by the feature caps you at one of each and makes the second one indistinguishable from the first.
4. Open only what the frame can carry
A composed page is usually embeddable itself, and the frame it lands in is not always the whole screen. hosted on your own feature handle answers whether a host exists at all, and it answers synchronously, so the composition can be sized before a single session opens. Unhosted, nobody will ever tell you anything: open everything now. Hosted, hold the opens until the host says what it mounted, and put a deadline on that wait so a host that never speaks costs a beat rather than the page.
export function wireSceneBoot(link: PondLink, scene: PondScene, options: SceneBootOptions): void {
if (!options.hosted) {
scene.setScale('full')
return
}
const schedule =
options.schedule ??
((callback: () => void, afterMs: number): void => {
window.setTimeout(callback, afterMs)
})
let decided = false
// why: The contract wiring routes the scale itself; this subscription only stops the fallback from second-guessing a host that already spoke.
link.on('set-scene', () => {
decided = true
})
link.on('presentation', (data) => {
const mode = (data as PresentationMessage).mode
// why: Only the gallery's embedded mounts ever send scene semantics — a dialog, popup, or host-opened tab says everything it will ever say with its presentation, so the full profile opens now rather than after a silent second.
if (!decided && typeof mode === 'string' && mode !== 'embedded') {
decided = true
scene.setScale('full')
}
})
schedule(() => {
if (!decided) {
decided = true
scene.setScale('full')
}
}, SCENE_FALLBACK_MS)
}
e.g. apps/demos/koi-pond/host/src/feature/wire-contract.ts#L114-L143
Guessing is paid for in sessions: opening N features into a thumbnail spends N documents, N handshakes and N renderers on a surface showing a fraction of one. Decide once per session and hold it. A host whose presentation genuinely changes should destroy the composition and open it again for the new one, rather than rebuild a running scene underneath its own features.
5. 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.values()) {
session.shell.send('neighbors', relay.neighborsFor(session.id, pond, now))
}
}
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L968-L973
On the receiving side of a schema-less hot path, narrow the payload yourself. Keep that narrowing in the shared contract package, so every feature reading the action agrees on what counts as usable, and drop what fails rather than throwing: a malformed relay should cost you one entry, not the frame.
function readShoal(data: unknown): NeighborObservation[] {
// 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)
}
}
return observed
}
e.g. apps/demos/koi-pond/lib/src/contract/wire.ts#L134-L145
6. Choose security per boundary
Pin a session envelope on boundaries that cross trust: separately deployed sites, product meaning in every message. v3 keys each session with ephemeral keys agreed over the wire, so a script that can only listen reads nothing; v4 binds those keys to a sharedKey of at least 16 characters that both sides hold, so a script without the key can neither read frames nor forge them. Either costs one key agreement per session (plus one password stretch for v4) and one authenticated-encryption operation per message in each direction. Pin v3 or v4 in your config; the pond's pin names the protocol its own features release offers.
/** 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: 'v3',
})
e.g. apps/demos/koi-pond/host/src/hyperfrontend.feature.ts#L41-L46
Where a boundary is yours end to end, an open channel is a legitimate choice. 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.8.0',
contract: './koi-fish.contract.ts',
url: 'https://demo-koi-fish-vanilla-production.up.railway.app/',
// why: An open shell, acknowledged at pack time: the eight koi are same-origin sub-paths of one deploy, so the boundary is the pond's own. 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. Pin the same protocol on both sides: a session is fail-closed, so a counterpart that cannot run the selected protocol is denied, each side receives error with reason: 'security-unavailable', and the host tears the mount down.
7. 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.
8. 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', () => {
setPresent(id, false)
})
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L610-L612
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: Whatever went wrong, the frame is no longer showing a koi — it waits for a handshake to earn its place back.
setPresent(id, false)
// why: A timed-out handshake leaves a destroyed mount and the SDK never retries — on a slow device the 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 may still be alive, and must not be torn down under its visitor.
if ((data as ErrorReport)?.reason === 'open-timeout' && (retries.get(id) ?? 0) < OPEN_RETRIES) {
retries.set(id, (retries.get(id) ?? 0) + 1)
window.setTimeout(() => {
// why: The roster may have let this koi go while the retry waited; reopening a removed session would mount a frame into a layer the pond already tore down.
if (sessions.get(id) === session) {
shell.open()
}
}, OPEN_RETRY_DELAY_MS)
}
})
e.g. apps/demos/koi-pond/host/src/scene/pond.ts#L639-L654
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.