@hyperfrontend/featuresHow to detect and handle an unresponsive feature
Notice a failing feature within seconds, tell the visitor something honest, measure how well the connection is performing, and recover when the feature comes back.
An embedded feature is someone else's runtime living in your page. It can hang, crash, lose its renderer process, or sit in a background tab where the browser throttles its timers to a crawl, and none of that announces itself. Two signals answer two different questions, and either can be healthy while the other is not: the @hyperfrontend/features watchdog says the feature's frame is alive, your own contract events say it is doing its job. Watch both. The snippets come from the heartbeat demo, whose host keeps the two in separate panels.
1. Open the session
const shell = createShell({
modes: { embedded: mountEmbedded },
container: '#stage',
name: '@hyperfrontend/demo-heartbeat',
// why: Resolved against this page, so the same markup works under `hf dev`, `vite preview`, and any static origin serving the pair.
url: new URL('/', window.location.href).toString(),
contract,
// why: Matches the feature's declared protocol, so the pairing negotiates the v1 security envelope.
protocol: 'v1',
})
e.g. apps/demos/heartbeat/src/host/main.ts#L97-L106
The watchdog starts judging when the session opens, whether you got there through createShell or a generated shell package.
2. Stamp the product traffic you observe
Product silence is judged from timestamps you record yourself.
shell.on('beat', (data: unknown) => {
// note: Receive payloads are schema-validated by the SDK before consumer handlers run.
const beat = <BeatPayload>data
const receivedAt = Date.now()
totalBeats += 1
if (beat.source === 'user') {
userBeats += 1
log(`user beat #${beat.seq}`)
// why: An extra beat is the visitor's doing — the host celebrates it with a toast sticker drifting off the heart.
fx.spawnToast()
}
// why: Any beat ends a flatline, so the skull leaves immediately even mid-materialisation.
fx.hideSkull()
lastBeatAt = receivedAt
rolling.addBeat(receivedAt)
ecg.addBeat({ at: receivedAt, source: beat.source })
// note: Silent until the visitor approves sound; a flatlined rhythm emits no beats, so nothing can sound while flat.
audio.playBeat()
beatsTotalEl.textContent = String(totalBeats)
beatsUserEl.textContent = String(userBeats)
})
e.g. apps/demos/heartbeat/src/host/main.ts#L110-L130
Derive any displayed rate from the events you actually received, never from the feature's configured target.
3. Subscribe to the SDK's judgement
shell.on('status', (data: unknown) => {
// note: The SDK watchdog reports a snapshot object, not a bare state string.
const status = <HeartbeatStatus>data
sdkStateEl.textContent = status.state
sdkStateEl.dataset['state'] = status.state
sdkMissedEl.textContent = String(status.missedBeats)
sdkLastBeatEl.textContent = status.lastBeatAt === null ? '—' : `${Math.max(0, Date.now() - status.lastBeatAt)} ms ago`
log(`sdk liveness: ${status.state}`)
})
e.g. apps/demos/heartbeat/src/host/main.ts#L149-L157
Each transition hands you a HeartbeatStatus snapshot. Give each of its four states a different move:
healthy: clear whatever warning you raised.unobservable: say "can't judge right now", never "offline", because throttled timers make silence meaningless.suspect: decide per product. Report it verbatim when connection state is the product; do not demote a working embed on suspicion alone.gone: drop the session-scoped state you inferred from beats.
4. Judge product silence separately
Product silence is yours to define, from two inputs: the feature's own admission through a contract event, and silence measured against the last cadence you observed.
// how: The vitals tick twice a second: rolling BPM from received intervals, plus the flatline judgement from state or silence.
setInterval(() => {
const now = Date.now()
const flat = rhythmState === 'flatline' || isFlatline(now, lastBeatAt, lastKnownBpm)
ecg.setFlat(flat)
// how: The edge detector fires 'show' once per flatline period; the skull otherwise manages its own 10-second lifetime.
const skullEdge = flatlineEdge.evaluate(flat)
if (skullEdge === 'show') {
fx.showSkull()
} else if (skullEdge === 'hide') {
fx.hideSkull()
}
if (flat) {
ecgFlagEl.hidden = false
ecgFlagEl.textContent = 'FLATLINE'
ecgFlagEl.dataset['kind'] = 'flatline'
bpmEl.textContent = '0'
} else {
const bpm = rolling.bpmAt(now)
bpmEl.textContent = bpm === null ? '—' : String(bpm)
if (rhythmState === 'recovering') {
ecgFlagEl.hidden = false
ecgFlagEl.textContent = 'RECOVERING'
ecgFlagEl.dataset['kind'] = 'recovering'
} else {
ecgFlagEl.hidden = true
}
}
}, 500)
e.g. apps/demos/heartbeat/src/host/main.ts#L204-L232
5. Measure the round trip
Latency on top of state tells you how well the connection is performing, not just whether it is up. Send a probe on an interval and have the feature echo the send time back, so the difference covers the whole path.
{
type: 'ping',
description:
'Latency probe. Answered directly when sent as a request, and always echoed as a `pong` event carrying the original `sentAt`.',
schema: {
type: 'object',
properties: {
seq: { type: 'number' },
sentAt: { type: 'number' },
},
required: ['seq', 'sentAt'],
},
respondsWith: 'pong',
},
e.g. apps/demos/heartbeat/heartbeat.contract.ts#L16-L29
Declaring respondsWith names the reply action so the contract validates the pairing. What resolves the host's request is the feature registering a handler for the probe.
// how: The latency probe is a correlated request — the feature's responder echoes sentAt, and the pong event fires for plain listeners too.
setInterval(() => {
if (!shell.isOpen) {
return
}
pingSeq += 1
void shell
.request('ping', { seq: pingSeq, sentAt: Date.now() })
.then((reply) => {
latencyEl.textContent = String(Date.now() - (<PongPayload>reply).sentAt)
})
.catch(() => {
latencyEl.textContent = '—'
})
}, 2000)
e.g. apps/demos/heartbeat/src/host/main.ts#L186-L200
Skip the probe while isOpen is false, and handle rejection: requests reject when the channel closes before the reply arrives.
6. Reset your judgement when the session closes
A session that closes takes your product judgement with it, so reset everything you inferred from beats. Mid-session closes are often reloads: the SDK adopts the new document and traffic resumes, so stale judgement must not bleed into the new session.
shell.on('close', () => {
log('channel closed')
rolling.reset()
lastBeatAt = null
// why: The host stops judging a rhythm it no longer observes — otherwise closing mid-flatline pins the flag and skull forever.
rhythmState = 'beating'
fx.hideSkull()
})
e.g. apps/demos/heartbeat/src/host/main.ts#L165-L172
A suspect feature that resumes beating needs no reset at all. The next beat ends the episode and the watchdog re-arms.
el<HTMLButtonElement>('#close-btn').addEventListener('click', () => {
shell.close()
})
el<HTMLButtonElement>('#reopen-btn').addEventListener('click', () => {
// note: open() after close() remounts the same shell; a full destroy() is only for releasing the handle for good.
shell.open()
})
e.g. apps/demos/heartbeat/src/host/main.ts#L262-L269
Check it worked
Your host must walk these transitions without you touching the feature's code:
- Stop the feature's product traffic while leaving the frame alive. Your product-silence flag raises; the SDK state stays
healthywith zero missed beats. - Switch to another tab for a few seconds and come back. The state goes
unobservable, thenhealthy. - Close, then reopen. The state walks to
goneand back tohealthy.
Limits
suspectneeds visible silence, and returning to visibility grants a fresh miss budget, because throttled beats need time to resume. A feature that died while the visitor was on another tab reportsunobservable, thenhealthyfor up to about three seconds after they return, and only thensuspect. Wait for actual traffic before you call it recovered.- The watchdog runs a fixed cadence of one beat a second against a budget of three. For a different silence budget, run your own deadline over product events, and set an
onUnresponsivepolicy to choose what the shell does when the budget trips. - A same-origin feature shares the host's thread, so a busy spin freezes the host page with it and no watchdog anywhere gets to run.
suspectearns its keep with cross-origin features, which browsers typically isolate into their own processes.