Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
# `heartbeat`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the per-connection ping/pong state machine described in design §11.4. The engine instantiates one `HeartbeatController` per accepted connection, drives `tick()` from a timer, and routes pong frames into `recordPong()`. The controller decides whether to send another ping, do nothing, or abort the connection — that decision is delegated to `evaluateHeartbeat` in `@suaveplan/api-ws` so policy stays uniform across engines.
The controller is clock-injected so tests can use a `FakeClock` to advance time deterministically without relying on real timers.
## Features
- Tracks both wall-clock time and consecutive missed-pong counts.
- Emits `onPing` once per heartbeat window; double-`tick` inside the same window is a no-op so the engine can poll cheaply.
- Throws `WS_BUN.HEARTBEAT_FAILED` after `missedPongLimit` misses to keep the abort path uniform with the other WS_BUN failure modes.
- `aborted` and `missedPongs` accessors surface state to OTel instruments.
## Usage: Basic
```ts
import { HeartbeatController } from "@suaveplan/api-ws-bun";
const clock = { now: () => Date.now() };
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 30_000,
missedPongLimit: 2,
clock,
onPing: () => ws.ping(),
onAbort: () => ws.close(1011, "heartbeat"),
});
setInterval(() => ctl.tick(), 5_000);
ws.on("pong", () => ctl.recordPong());
```
## Usage: Advanced
```ts
import { FakeClock } from "@suaveplan/testing";
import { HeartbeatController, WS_BUN_CODES } from "@suaveplan/api-ws-bun";
const clock = new FakeClock();
let aborted = false;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => {
aborted = true;
},
});
clock.advance(1000);
ctl.tick(); // ping 1
clock.advance(1000);
try {
ctl.tick(); // 2 missed → abort
} catch (err) {
// err.code === WS_BUN_CODES.HEARTBEAT_FAILED
}
```
## API Reference
- `HeartbeatClock` — `{ now(): number }`.
- `HeartbeatControllerOptions` — `{ connectionId, heartbeatMs, missedPongLimit, clock, onPing, onAbort }`.
- `new HeartbeatController(options)` — constructor.
- `controller.tick(): void` — advance the state machine.
- `controller.recordPong(): void` — reset the missed-counter.
- `controller.aborted: boolean` — true once the abort transition has fired.
- `controller.missedPongs: number` — current missed-pong count.
## Implementation Notes
`tick()` checks `now - lastPingSent` to avoid spamming pings on every poll — the controller pings only once per window. This makes the engine free to call `tick()` at any cadence shorter than `heartbeatMs` without affecting wire behaviour.
After an abort fires, `recordPong()` and subsequent `tick()` calls become no-ops. The engine still receives the `onAbort` callback exactly once because the throw immediately follows it, so any double-bookkeeping at the engine layer is guaranteed safe.

View File

@@ -0,0 +1,140 @@
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WS_BUN_CODES, WsBunError } from "../error-codes/error-codes.js";
import { HeartbeatController } from "./heartbeat.js";
describe("HeartbeatController", () => {
it("ticks within window are no-op", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(500);
ctl.tick();
expect(pings).toBe(0);
});
it("sends a ping after one window", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
expect(pings).toBe(1);
expect(ctl.missedPongs).toBe(1);
});
it("aborts after missedPongLimit consecutive misses", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 1_000 });
let aborted = false;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => {
aborted = true;
},
});
clock.advance(1000);
ctl.tick(); // miss 1
clock.advance(1000);
ctl.tick(); // miss 2
clock.advance(1000);
try {
ctl.tick(); // misses (2) >= limit (2) -> abort
throw new Error("expected throw");
} catch (err) {
expect(err instanceof WsBunError).toBe(true);
expect((err as WsBunError).wsBunCode).toBe(
WS_BUN_CODES.HEARTBEAT_FAILED
);
}
expect(aborted).toBe(true);
expect(ctl.aborted).toBe(true);
});
it("recordPong resets miss counter", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 2,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
expect(ctl.missedPongs).toBe(1);
ctl.recordPong();
expect(ctl.missedPongs).toBe(0);
});
it("ticks after abort are no-op", { timeout: 5000 }, () => {
const clock = new FakeClock({ startTime: 1_000 });
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 1,
clock: { now: () => clock.now() },
onPing: () => undefined,
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick(); // misses -> 1
clock.advance(1000);
try {
ctl.tick(); // misses (1) >= limit (1) -> abort
} catch {
/* abort */
}
expect(ctl.aborted).toBe(true);
// After abort, recordPong + tick must be no-ops.
ctl.recordPong();
ctl.tick();
expect(ctl.aborted).toBe(true);
});
it("repeat tick inside the same window does not re-ping", {
timeout: 5000,
}, () => {
const clock = new FakeClock({ startTime: 1_000 });
let pings = 0;
const ctl = new HeartbeatController({
connectionId: "c1",
heartbeatMs: 1000,
missedPongLimit: 5,
clock: { now: () => clock.now() },
onPing: () => {
pings += 1;
},
onAbort: () => undefined,
});
clock.advance(1000);
ctl.tick();
ctl.tick();
ctl.tick();
expect(pings).toBe(1);
});
});

View File

@@ -0,0 +1,84 @@
import { evaluateHeartbeat } from "@suaveplan/api-ws";
import { raiseHeartbeatFailed } from "../error-codes/error-codes.js";
/**
* Clock abstraction used by the heartbeat controller. The engine wires
* `Date.now` in production; tests pass a `FakeClock`.
*/
export interface HeartbeatClock {
now(): number;
}
/** Constructor options for {@link HeartbeatController}. */
export interface HeartbeatControllerOptions {
readonly connectionId: string;
readonly heartbeatMs: number;
readonly missedPongLimit: number;
readonly clock: HeartbeatClock;
readonly onPing: () => void;
readonly onAbort: () => void;
}
/**
* Per-connection heartbeat state machine. The engine drives `tick()` on a
* timer (or directly from a test-controlled `FakeClock`); the controller
* compares the current view of "ms since last pong" + "consecutive missed
* pongs" against the contract's `evaluateHeartbeat` evaluator.
*/
export class HeartbeatController {
private lastPong: number;
private misses = 0;
private lastPingSent: number;
private done = false;
public constructor(private readonly options: HeartbeatControllerOptions) {
this.lastPong = options.clock.now();
this.lastPingSent = options.clock.now();
}
/** Called when a pong frame arrives. Resets the missed-counter. */
public recordPong(): void {
if (this.done) return;
this.lastPong = this.options.clock.now();
this.misses = 0;
}
/**
* Advance the state machine. Emits `onPing` when the heartbeat
* interval has elapsed, or `onAbort` (plus throws
* `WS_BUN.HEARTBEAT_FAILED`) after `missedPongLimit` consecutive
* misses.
*/
public tick(): void {
if (this.done) return;
const now = this.options.clock.now();
const elapsed = now - this.lastPong;
const action = evaluateHeartbeat(elapsed, this.misses, {
heartbeatMs: this.options.heartbeatMs,
maxMissedPongs: this.options.missedPongLimit,
});
if (action === "abort") {
this.done = true;
this.options.onAbort();
raiseHeartbeatFailed(this.options.connectionId);
}
if (action === "ping") {
// Only send a ping if we have not pinged this window already.
if (now - this.lastPingSent >= this.options.heartbeatMs) {
this.lastPingSent = now;
this.misses += 1;
this.options.onPing();
}
}
}
/** Whether the controller has fired its abort transition. */
public get aborted(): boolean {
return this.done;
}
/** Current missed-pong count — surfaced for instruments. */
public get missedPongs(): number {
return this.misses;
}
}

View File

@@ -0,0 +1,5 @@
export {
type HeartbeatClock,
HeartbeatController,
type HeartbeatControllerOptions,
} from "./heartbeat.js";