# channel/heartbeat ## Purpose `HeartbeatRunner` is a tiny lifecycle helper that owns the pair of `setInterval` timers a `TypedBroadcastChannel` needs to keep peer presence warm. The heartbeat callback fires once per `heartbeatInterval` and is meant to post a `heartbeat` frame plus refresh the local tab's `lastSeen`. The cleanup callback fires once per `heartbeatInterval * 2` and is meant to walk the known-tabs map and evict any peer whose `lastSeen` is older than the configured `tabTimeout`. The helper exists because the upstream library packed both intervals into `channel.ts` inline; pulling them out keeps the channel core inside the 300-line hard budget mandated by `AGENTS.md` §3 and gives each timer lifecycle a discrete unit test surface. The runner has no view into the channel — it only fires the callbacks it was given, so it composes with any substitute clock or scheduler the consumer wants to thread. ## Features - Two independent `setInterval`s driven by a single `heartbeatInterval`. - Idempotent `stop()` — safe to call before `start()` or twice in a row. - No shared state with the channel; lifecycle ownership is local to the runner. ## Usage: Basic ```ts import { HeartbeatRunner } from "@suaveplan/broadcast-channel"; const runner = new HeartbeatRunner({ heartbeatInterval: 1_000, onHeartbeat: () => console.log("ping"), onCleanup: () => console.log("gc"), }); runner.start(); // later runner.stop(); ``` ## Usage: Advanced ```ts import { HeartbeatRunner } from "@suaveplan/broadcast-channel"; class Presence { private readonly runner = new HeartbeatRunner({ heartbeatInterval: 2_000, onHeartbeat: () => this.broadcastSelf(), onCleanup: () => this.evictStale(), }); public open(): void { this.runner.start(); } public close(): void { this.runner.stop(); } private broadcastSelf(): void { // post heartbeat frame ... } private evictStale(): void { // remove peers whose lastSeen is past tabTimeout ... } } ``` ## API Reference - `class HeartbeatRunner` - `constructor(opts: HeartbeatRunnerOptions)` - `start(): void` — schedules both intervals; calling twice replaces the timers. - `stop(): void` — clears both intervals if running; safe to call repeatedly. - `interface HeartbeatRunnerOptions` - `heartbeatInterval: number` — millisecond cadence for `onHeartbeat`. - `onHeartbeat: () => void` - `onCleanup: () => void` — fires at `heartbeatInterval * 2`. ## Implementation Notes The cleanup cadence is intentionally twice the heartbeat cadence: this gives peers at least one heartbeat opportunity to refresh `lastSeen` before a cleanup tick can evict them, even under jitter. The runner stores the two handles as `ReturnType` so the same code typechecks under both Bun and Node ambient lib variants. Because `start()` re-assigns the handles without clearing the prior ones first, callers must call `stop()` before re-starting — `TypedBroadcastChannel` does exactly that during its `close()`/`open()` cycle. Tests pin the cadence at 20 ms so the suite stays fast while still exercising at least one of each callback.