@foghorn/sdk
v0.5.0
Published
Browser client for foghorn — connect, subscribe, and receive real-time WebSocket messages with reconnect/backoff built in.
Maintainers
Readme
@foghorn/sdk
Browser client for foghorn —
a thin wrapper over the native WebSocket for connecting, subscribing, and
receiving real-time messages. Handles reconnect with backoff and
auto-resubscribe for you, plus a ping/pong heartbeat that detects a
connection API Gateway (or anything in between) dropped without ever
delivering a close frame — otherwise invisible, since readyState stays
OPEN on a connection like that indefinitely. There's still no way to emit
with the secret key from here, and never will be — that stays a
server-to-server call. This client does support emit() for scoped
client-emit, authorized by a short-lived token minted server-side (see
@foghorn/sdk/server below).
Install
npm install @foghorn/sdkUsage
import { FoghornClient } from "@foghorn/sdk";
const client = new FoghornClient({
url: "wss://<your-project-ws-url>", // from your project's dashboard page
publicKey: "pk_live_xxxxxxxx",
});
client.onOpen(() => client.subscribe("room-1"));
client.onMessage((msg) => console.log(msg.channel, msg.payload));
client.connect();Document edits and anything needing a durable, revision-checked write still
go through your own backend over plain HTTP with the secret key — that path
isn't part of this client. For ephemeral, high-frequency signals
(cursor/selection/focus/blur/identity) where that round trip doesn't pull
its weight, emit() lets the browser publish directly, authorized by a
short-lived, channel-scoped, clientId-bound, type-allowlisted token
(minted server-side via mintClientEmitToken from @foghorn/sdk/server) —
never the secret key:
client.subscribe("template:42", { presence: true, clientId: "user-123" });
client.emit("template:42", "cursor", { x: 120, y: 84 }, token); // token from your backend, see @foghorn/sdk/server belowOptional per-subscription features: presence (who else is in a channel,
via onPresence — join/leave/sync events, no per-user metadata beyond
clientId) and a short-lived replay buffer for reconnects (onReplayGap
fires when a since request couldn't fully catch up), both opt-in via
subscribe(channel, options).
API
| Method | Signature | Behavior |
|---|---|---|
| connect() | (): void | Opens the connection |
| disconnect() | (): void | Closes the connection; suppresses auto-reconnect |
| subscribe(channel, options?) | (channel: string, options?: {presence?, clientId?, since?}): void | Subscribes; remembered (with options) so a future reconnect resubscribes automatically, requesting replay from the last seq actually seen |
| unsubscribe(channel) | (channel: string): void | Unsubscribes; forgets the channel (and its tracked seq) |
| getLastSeq(channel) | (channel: string): number \| undefined | Highest seq seen on channel this session — persist it yourself (e.g. localStorage) to resume via since after a full page reload |
| emit(channel, type, payload, token) | (channel: string, type: string, payload: unknown, token: string): void | Emits directly to channel over this connection using a scoped token minted by @foghorn/sdk/server |
| onMessage(handler) | (msg: {channel, payload, seq, replay?, type?, clientId?}) => void → unsubscribe fn | Fires for emitted messages on subscribed channels; replay: true if delivered from the buffer rather than live; type/clientId present when sent via client-emit |
| onPresence(handler) | (event: {type: "presence", channel, event, ...}) => void → unsubscribe fn | Fires for join/leave/sync events on channels subscribed with presence: true |
| onReplayGap(handler) | (event: {type: "replay_gap", channel, since}) => void → unsubscribe fn | Fires when a replay request couldn't fully catch up — fall back to a full resync |
| onAck(handler) | (ack: {action, channel, ok, seq?}) => void → unsubscribe fn | Fires on subscribe/unsubscribe/emit acks; seq present for emit |
| onErrorMessage(handler) | (err: {error, ...}) => void → unsubscribe fn | Fires on server-sent protocol errors |
| onOpen(handler) | () => void → unsubscribe fn | Fires when the socket opens, including after a reconnect |
| onClose(handler) | ({code, reason}) => void → unsubscribe fn | Fires on every close |
| onSocketError(handler) | (event: unknown) => void → unsubscribe fn | Fires on the underlying WebSocket's error event |
Options
| Option | Type | Default |
|---|---|---|
| url | string | required |
| publicKey | string | required |
| reconnect | boolean | true |
| maxReconnectDelayMs | number | 30000 |
| heartbeatIntervalMs | number | 240000 (4 min) — how often to send ping; 0 disables it |
| heartbeatAckTimeoutMs | number | 15000 — how long to wait for pong before closing the socket and letting reconnect logic take over |
Heartbeat
Detects a connection API Gateway (or anything in between) dropped without
ever delivering a close frame — sleep/wake, a NAT/proxy timeout, a
backgrounded tab. readyState stays OPEN on a connection like that
indefinitely, so the client pings itself on heartbeatIntervalMs and, if
heartbeatAckTimeoutMs passes with no pong, closes the socket itself
(HEARTBEAT_TIMEOUT_CLOSE_CODE, reason "heartbeat_timeout") — which feeds
the existing reconnect path the same as any other unexpected close:
import { FoghornClient, HEARTBEAT_TIMEOUT_CLOSE_CODE } from "@foghorn/sdk";
const client = new FoghornClient({
url: "wss://<your-project-ws-url>",
publicKey: "pk_live_xxxxxxxx",
heartbeatIntervalMs: 60_000, // ping every minute instead of the 4 min default
heartbeatAckTimeoutMs: 5_000, // give up after 5s instead of 15s
});
client.onClose(({ code, reason }) => {
if (code === HEARTBEAT_TIMEOUT_CLOSE_CODE) {
console.warn("connection died silently; reconnecting", reason);
}
});Set heartbeatIntervalMs: 0 to disable it entirely.
@foghorn/sdk/server
A separate, Node-only entry point — never import this from browser code.
Mints and revokes scoped client-emit tokens for emit() above. Both
functions are dependency-free (only node:crypto/fetch) and derive
everything they need from values you already have — no extra endpoint URLs
to look up or configure.
import { mintClientEmitToken, revokeClientEmitToken } from "@foghorn/sdk/server";
const token = mintClientEmitToken({
secret: process.env.FOGHORN_CLIENT_EMIT_SECRET!, // from the dashboard, never the public/secret API key
projectId: "...",
channel: "template:42",
clientId: "user-123", // must match the clientId this connection subscribes with (presence: true)
types: ["cursor", "selection"],
ttlSeconds: 300,
});
// Invalidate a token (or every token for a clientId) ahead of its TTL —
// e.g. on logout. Derives the revoke URL from the same emitUrl you already
// use for backend emit() calls — it's a sibling path, not a separate one.
await revokeClientEmitToken({
emitUrl: process.env.FOGHORN_EMIT_URL!,
secretKey: process.env.FOGHORN_SECRET_KEY!,
clientId: "user-123", // and/or jti to revoke one specific token
});License
MIT
