Files
buzz/desktop/src/shared/api/relayClientSession.ts
T
e5efd04705 fix(desktop): close reconnect gaps that previously required CMD+R (#4737)
> Opened by Brain (agent) on behalf of @wesbillman.

## Problem

Users report the desktop app doesn't reliably reconnect and can wedge in
states where only CMD+R (or a full restart) restores connectivity
(thread `c2205e2b` in #desktop-reconnecting).

Pinky's empirical light-switch matrix (real `buzz-relay`, SIGTERM/1012 +
SIGKILL × 1s/45s/3min, at `f18a9cb10`) passed 4/4 — the backoff state
machine recovers cleanly from ordinary relay loss. That isolates the
user-stuck states to four special cases a reload resets but the auto
flow never did.

## Fixes

| Gap | Change |
|---|---|
| **G1** — recovery rode solely on the backoff timer (max 30s),
throttled by WKWebView in occluded/background windows; nothing fired on
network return or wake | New `useRelayResumeTriggers`: `online`, window
focus, and visibility→visible call `preconnect()` when the session is
`reconnecting`/`stalled`, rate-limited to one attempt per 5s
(`relayResumeTriggerPolicy.ts`). Deliberately inert for the terminal
`disconnected` state. |
| **G2** — any AUTH `OK false` latched the session terminal forever,
though the relay also rejects for transient causes (duplicate-AUTH
"already authenticated" race, ±60s clock skew, fail-closed allowlist DB
errors) | New `AuthOkTracker` (`relayAuthPolicy.ts`): "already
authenticated" resolves as success; transient rejections retry with
normal backoff; latch only on `restricted:` or after 3 consecutive
rejections. |
| **G3** — an `auth-required:` CLOSED (REQ racing AUTH after reconnect)
permanently deleted the live subscription with no UI signal — frozen
channel while state reads "connected" | Reclassified `auth-required:` as
retryable in `relayClosedPolicy.ts`. Genuinely terminal classes
(`restricted:`, `invalid:`, …) still delete. Can't loop: a truly
unauthenticated session latches terminal at the connection level. |
| **G4** — `useRelayAutoHeal` observed the 2s-debounced connection hook,
so sub-2s flaps never triggered the heal even though `resetConnection`
had already rejected every in-flight query | Auto-heal now observes the
raw connection-state emitter. The existing 15s heal rate-limit still
guards against flap storms. |

Each fix is a colocated pure-policy module + unit tests, matching the
existing `relayReconnectPolicy`/`relayClosedPolicy` pattern.

## Validation

- Full desktop unit suite: **4151 pass, 0 fail** (at branch tip, `pnpm
-C desktop test`)
- `pnpm -C desktop typecheck` and `pnpm -C desktop check` clean
(file-size ratchet respected — `relayClientSession.ts` net −2 lines
despite the tracker wiring)
- Evidence trail: `RESEARCH/DESKTOP_RECONNECT_CMDR_GAP_AUDIT.md`
(audit), `RESEARCH/DESKTOP_RECONNECT_LIGHT_SWITCH_RESULTS.md` (Pinky's
matrix)

## Not covered / follow-ups

- Native macOS sleep-wake was not automated (would kill the harness
session); G1's focus trigger is the mechanism that covers wake in
practice, but a manual sleep-wake verification on a real build is
worthwhile.
- G3 terminal-CLOSED classes (`restricted:` etc.) still silently delete
subs with no UI signal — surfacing that is a separate UX decision.
- Stall-watchdog latency (60s idle + 10s check) left unchanged; G1
triggers largely mask it.

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Signed-off-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
Co-authored-by: npub1yxv5wk0u0fh6dwt925wntn7h397jvteyj4r87ttcd9xae7n2t3lqqj9jmm <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@buzz.block.builderlab.xyz>
Co-authored-by: npub1gjuws2a2dc8z2nszprtg7v6u9q7ffeah3hgl5yx45jwn7y7aqs6s5e9xj6 <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@buzz.block.builderlab.xyz>
2026-08-04 18:33:42 +00:00

1088 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Channel, invoke } from "@tauri-apps/api/core";
import {
createAuthEvent,
getRelayWsUrl,
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
import {
KIND_STREAM_MESSAGE,
KIND_TYPING_INDICATOR,
KIND_USER_STATUS,
CHANNEL_EVENT_KINDS,
KIND_CHANNEL_THREAD_SUMMARY,
} from "@/shared/constants/kinds";
import {
getTextPayload,
type ConnectionState,
type PendingEvent,
type RelaySubscription,
type RelaySubscriptionFilter,
} from "@/shared/api/relayClientShared";
import {
buildChannelAuxDeletionFilter,
buildChannelFilter,
buildChannelHistoryFilter,
buildChannelMentionFilter,
buildGlobalStreamFilter,
} from "@/shared/api/relayChannelFilters";
import {
clearClosedRetry,
handleRelayClosed,
handleSubscriptionEose,
prepareSubscriptionEvent,
} from "@/shared/api/relayClosedRecovery";
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
import {
activateRateLimit,
parseRateLimitHint,
waitForRateLimit,
} from "@/shared/api/relayRateLimitGate";
import {
fetchChunkedHistory,
requestFirstEventGated,
requestHistoryGated,
} from "@/shared/api/relayGateBoundary";
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
import {
isServiceRestartClose,
isWebSocketClose,
isWebSocketError,
shouldRefuseConnect,
shouldScheduleReconnect,
shouldWaitForScheduledReconnect,
} from "@/shared/api/relayReconnectPolicy";
import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters";
import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog";
import {
AUTH_TIMEOUT_MS,
BACKOFF_RESET_STABLE_MS,
EVENT_BATCH_MS,
HISTORY_TIMEOUT_MS,
PUBLISH_TIMEOUT_MS,
RECONNECT_BASE_DELAY_MS,
RECONNECT_MAX_DELAY_MS,
STALL_CHECK_INTERVAL_MS,
STALL_IDLE_TIMEOUT_MS,
} from "@/shared/api/relayClientTimings";
import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
import { AuthOkTracker } from "@/shared/api/relayAuthPolicy";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
export class RelayClient {
private wsId: number | null = null;
private relayUrl: string | null = null;
private connectPromise: Promise<void> | null = null;
private reconnectTimeout: number | null = null;
private reconnectWaiters = new RelayReconnectWaiters();
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
private keepAliveRequested = false;
private authRequest: {
pendingEventId: string;
resolve: () => void;
reject: (error: Error) => void;
timeout: number;
} | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
private eventBuffer: Array<{ subId: string; event: RelayEvent }> = [];
private flushTimeout: number | null = null;
private reconnectListeners = new Set<() => void>();
private hasConnectedOnce = false;
private notifyReconnectListeners = false;
private onMessageChannel: Channel<unknown> | null = null;
private connectionGeneration = 0;
private stabilityTimer: number | null = null;
private visibleChannelId: string | null = null;
private authOkTracker = new AuthOkTracker();
private terminal = false;
private connectionStateEmitter = new RelayConnectionStateEmitter("idle");
private stallWatchdog = new RelayStallWatchdog({
intervalMs: STALL_CHECK_INTERVAL_MS,
idleTimeoutMs: STALL_IDLE_TIMEOUT_MS,
onStall: (error) => {
this.connectionStateEmitter.set("stalled");
this.resetConnection(error);
},
});
setVisibleChannelId(id: string | null) {
this.visibleChannelId = id;
}
disconnect() {
const error = new Error("Relay disconnected for community switch.");
if (this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.stabilityTimer !== null) {
window.clearTimeout(this.stabilityTimer);
this.stabilityTimer = null;
}
this.stallWatchdog.stop();
this.connectionGeneration++;
this.keepAliveRequested = false;
this.relayUrl = null;
this.hasConnectedOnce = false;
this.notifyReconnectListeners = false;
this.terminal = false;
this.visibleChannelId = null;
this.authOkTracker.reset();
this.connectionStateEmitter.set("idle");
if (this.wsId !== null) {
void closeWebSocket(this.wsId, "community switch");
this.wsId = null;
}
this.connectPromise = null;
this.reconnectWaiters.settle(error);
if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
this.authRequest.reject(error);
this.authRequest = null;
}
for (const [subId, sub] of this.subscriptions) {
if (sub.mode !== "live") {
window.clearTimeout(sub.timeout);
sub.reject(error);
} else {
clearClosedRetry(sub);
}
this.subscriptions.delete(subId);
}
for (const [eventId, pending] of this.pendingEvents) {
window.clearTimeout(pending.timeout);
pending.reject(error);
this.pendingEvents.delete(eventId);
}
if (this.flushTimeout !== null) {
window.clearTimeout(this.flushTimeout);
this.flushTimeout = null;
}
this.eventBuffer = [];
this.reconnectListeners.clear();
this.connectionStateEmitter.clear();
this.onMessageChannel = null;
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
}
async fetchChannelHistory(channelId: string, limit = 50) {
return this.fetchHistory(buildChannelHistoryFilter(channelId, limit));
}
async fetchChannelHistoryBefore(
channelId: string,
before: number,
limit = 50,
) {
return this.fetchHistory(
buildChannelHistoryFilter(channelId, limit, before),
);
}
async fetchAuxEventsByReference(
channelId: string,
referencedEventIds: string[],
buildFilter: (
channelId: string,
eventIds: string[],
) => RelaySubscriptionFilter,
) {
return fetchChunkedHistory(
referencedEventIds,
(eventIds) => buildFilter(channelId, eventIds),
(filter) => this.fetchHistory(filter),
);
}
async fetchAuxDeletionEventsForAuxEvents(
channelId: string,
auxEventIds: string[],
): Promise<RelayEvent[]> {
return fetchChunkedHistory(
auxEventIds,
(eventIds) => buildChannelAuxDeletionFilter(channelId, eventIds),
(filter) => this.fetchHistory(filter),
);
}
async fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]> {
return this.fetchHistory(filter);
}
async fetchFirstEvent(
filter: RelaySubscriptionFilter,
): Promise<RelayEvent | null> {
await this.ensureConnected();
return requestFirstEventGated(
this.subscriptions,
(payload) => this.sendRaw(payload),
(subId) => this.closeSubscription(subId),
filter,
HISTORY_TIMEOUT_MS,
);
}
private async fetchHistory(filter: RelaySubscriptionFilter) {
await this.ensureConnected();
return this.requestHistory(filter);
}
private requestHistory(
filter: RelaySubscriptionFilter,
): Promise<RelayEvent[]> {
return requestHistoryGated(
this.subscriptions,
(payload) => this.sendRaw(payload),
(subId) => this.closeSubscription(subId),
filter,
HISTORY_TIMEOUT_MS,
);
}
async sendMessage(
channelId: string,
content: string,
mentionPubkeys: string[] = [],
extraTags: string[][] = [],
) {
await this.ensureConnected();
const tags: string[][] = [["h", channelId]];
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
for (const tag of extraTags) {
tags.push(tag);
}
const event = await signRelayEvent({
kind: KIND_STREAM_MESSAGE,
content: content.trim(),
tags,
});
return this.publishEvent(
event,
"Timed out while sending the message.",
"Failed to send the message.",
);
}
async sendPresence(status: PresenceStatus) {
await this.ensureConnected();
const event = await signRelayEvent({
kind: 20001,
content: status,
tags: [],
});
return this.publishEvent(
event,
"Timed out while updating presence.",
"Failed to update presence.",
);
}
async sendTypingIndicator(
channelId: string,
parentEventId?: string | null,
rootEventId?: string | null,
) {
// Disconnected: not worth triggering a reconnect for ephemeral typing.
if (this.wsId === null) {
return;
}
const event = await signRelayEvent({
kind: KIND_TYPING_INDICATOR,
content: "",
tags: buildThreadReferenceTags(
channelId,
parentEventId ?? null,
rootEventId ?? null,
),
});
// Fire-and-forget: no need to wait for relay acknowledgement.
void this.sendRaw(["EVENT", event]).catch(() => {});
}
async subscribeToChannel(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(buildChannelFilter(channelId, 50), onEvent);
}
/** Subscribe to channel rows and aux starting now, with no history replay. */
async subscribeToChannelLive(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
// 39005 rides only this window-store subscription — CHANNEL_EVENT_KINDS'
// other consumers (unread tracking, cache merges) must never see
// summary overlays.
return this.subscribe(
{
kinds: [...CHANNEL_EVENT_KINDS, KIND_CHANNEL_THREAD_SUMMARY],
"#h": [channelId],
limit: 1000,
since: Math.floor(Date.now() / 1_000),
},
onEvent,
);
}
/**
* Subscribe to huddle lifecycle events (kinds 48100–48103) for a channel,
* so HuddleIndicator detects active huddles without being drowned out by
* regular channel messages. Includes the last 10 historical events.
*/
async subscribeToHuddleEvents(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(
{
kinds: [48100, 48101, 48102, 48103],
"#h": [channelId],
limit: 100,
},
onEvent,
);
}
async subscribeToTypingIndicators(
channelId: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(
{
kinds: [KIND_TYPING_INDICATOR],
"#h": [channelId],
limit: 10,
since: Math.floor(Date.now() / 1_000) - 10,
},
onEvent,
);
}
async subscribeToPresenceUpdates(onEvent: (event: RelayEvent) => void) {
return this.subscribe({ kinds: [20001], limit: 0 }, onEvent);
}
async publishUserStatus(text: string, emoji: string): Promise<void> {
await this.ensureConnected();
const tags: string[][] = [["d", "general"]];
if (emoji) tags.push(["emoji", emoji]);
const event = await signRelayEvent({
kind: KIND_USER_STATUS,
content: text,
tags,
});
await this.publishEvent(
event,
"Timed out publishing user status",
"Failed to publish user status",
);
}
/** Subscribe to kind:30315 user status events (live only, no backfill). */
async subscribeToUserStatusUpdates(onEvent: (event: RelayEvent) => void) {
return this.subscribe(
{ kinds: [KIND_USER_STATUS], "#d": ["general"], limit: 0 },
onEvent,
);
}
async subscribeToAllStreamMessages(onEvent: (event: RelayEvent) => void) {
return this.subscribe(buildGlobalStreamFilter(50), onEvent);
}
async subscribeLive(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(filter, onEvent);
}
async subscribeToChannelMentionEvents(
channelId: string,
pubkey: string,
onEvent: (event: RelayEvent) => void,
) {
return this.subscribe(
buildChannelMentionFilter(channelId, pubkey, 50),
onEvent,
);
}
async preconnect() {
// Explicit re-engagement (reconnect card / community switch): clears the
// terminal latch and AUTH rejection streak, and bypasses backoff once.
this.terminal = false;
this.authOkTracker.reset();
this.keepAliveRequested = true;
await this.connectBypassingBackoff();
}
/**
* Environment-driven resume (online/focus/visibility): bypasses a pending
* backoff timer but preserves the terminal latch and AUTH rejection streak
* — only `preconnect()` clears those, so resume events during repeated
* AUTH rejection cannot defeat the consecutive-rejection cap.
*/
async resumeReconnect() {
if (this.terminal) return;
await this.connectBypassingBackoff();
}
private async connectBypassingBackoff() {
if (this.reconnectTimeout !== null) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
try {
await this.ensureConnected();
this.reconnectWaiters.settle();
} catch (error) {
this.reconnectWaiters.settle(
this.normalizeRelayError(error, "Relay reconnect failed."),
);
throw error;
}
}
subscribeToReconnects(listener: () => void) {
this.reconnectListeners.add(listener);
return () => {
this.reconnectListeners.delete(listener);
};
}
/** Current connection state — synchronous read. */
getConnectionState(): ConnectionState {
return this.connectionStateEmitter.get();
}
/**
* Subscribe to connection-state transitions. The listener fires
* immediately with the current state, so callers need no separate
* `getConnectionState()` call to seed their UI.
*/
subscribeToConnectionState(listener: (state: ConnectionState) => void) {
return this.connectionStateEmitter.subscribe(listener);
}
private async ensureConnected() {
if (shouldRefuseConnect({ terminal: this.terminal })) {
// Terminal (e.g. relay rejected auth): refuse until disconnect() or
// preconnect() clears the latch, else the reconnect-timer catch and
// the publish/subscribe retry wrappers would race the terminal
// "disconnected" state back to "reconnecting".
throw new Error("Relay session is terminal; cannot reconnect.");
}
if (this.connectPromise) {
return this.connectPromise;
}
if (this.wsId !== null) {
return;
}
if (
shouldWaitForScheduledReconnect({
hasPendingReconnect: this.reconnectTimeout !== null,
})
) {
// The reconnect coordinator owns outage pacing. Query, publish, and
// subscription callers must wait for its scheduled attempt instead of
// clearing the timer and creating an immediate reconnect storm.
return this.reconnectWaiters.wait();
}
const connectPromise = this.connect();
this.connectPromise = connectPromise;
try {
await connectPromise;
} finally {
if (this.connectPromise === connectPromise) {
this.connectPromise = null;
}
}
}
private async connect() {
if (this.stabilityTimer !== null) {
window.clearTimeout(this.stabilityTimer);
this.stabilityTimer = null;
}
this.connectionStateEmitter.set(
this.hasConnectedOnce ? "reconnecting" : "connecting",
);
const generation = ++this.connectionGeneration;
this.onMessageChannel = new Channel<unknown>((message) => {
void this.handleWsMessage(message, generation).catch((error) => {
if (generation !== this.connectionGeneration) return;
this.resetConnection(
this.normalizeRelayError(error, "Relay connection errored."),
);
});
});
try {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const wsId = await invoke<number>("plugin:websocket|connect", {
url: this.relayUrl,
onMessage: this.onMessageChannel,
config: {},
});
if (generation !== this.connectionGeneration) {
void closeWebSocket(wsId, "stale connection attempt");
throw new Error("Relay connection attempt was superseded.");
}
this.wsId = wsId;
await new Promise<void>((resolve, reject) => {
const timeout = window.setTimeout(() => {
const error = new Error("Relay authentication timed out.");
this.authRequest = null;
this.resetConnection(error);
reject(error);
}, AUTH_TIMEOUT_MS);
this.authRequest = {
pendingEventId: "",
resolve,
reject,
timeout,
};
});
this.stabilityTimer = window.setTimeout(() => {
this.stabilityTimer = null;
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
}, BACKOFF_RESET_STABLE_MS);
this.connectionStateEmitter.set("connected");
await this.replayLiveSubscriptions();
this.stallWatchdog.start();
this.emitReconnectIfNeeded();
} catch (error) {
const connectionError = this.normalizeRelayError(
error,
"Failed to connect to relay.",
);
if (generation === this.connectionGeneration) {
this.resetConnection(connectionError);
}
throw connectionError;
}
}
private async subscribe(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
) {
await this.ensureConnected();
const subId = `live-${crypto.randomUUID()}`;
let resolveReady = () => {
return;
};
const ready = new Promise<void>((resolve) => {
resolveReady = () => {
window.clearTimeout(fallbackTimeout);
resolve();
};
});
const fallbackTimeout = window.setTimeout(() => {
resolveReady();
}, 250);
this.subscriptions.set(subId, {
mode: "live",
filter,
onEvent,
resolveReady,
});
try {
await this.sendRawWithReconnectRetry(
["REQ", subId, filter],
"Failed to restore relay subscription.",
);
} catch (error) {
window.clearTimeout(fallbackTimeout);
this.subscriptions.delete(subId);
throw error;
}
await ready;
return async () => {
const active = this.subscriptions.get(subId);
if (active?.mode !== "live") {
return;
}
this.subscriptions.delete(subId);
clearClosedRetry(active);
await this.closeSubscription(subId);
};
}
private async sendRaw(payload: unknown[]) {
if (this.wsId === null) {
throw new Error("Relay socket is not connected.");
}
await invoke("plugin:websocket|send", {
id: this.wsId,
message: {
type: "Text",
data: JSON.stringify(payload),
},
});
}
private normalizeRelayError(error: unknown, fallbackMessage: string) {
return error instanceof Error ? error : new Error(fallbackMessage);
}
private recoverFromSocketFailure(
error: unknown,
fallbackMessage: string,
): Error {
const normalizedError = this.normalizeRelayError(error, fallbackMessage);
this.resetConnection(normalizedError);
return normalizedError;
}
private async sendRawWithReconnectRetry(
payload: unknown[],
fallbackMessage: string,
) {
try {
await this.sendRaw(payload);
} catch (error) {
const normalizedError = this.recoverFromSocketFailure(
error,
fallbackMessage,
);
try {
await this.ensureConnected();
await this.sendRaw(payload);
} catch (retryError) {
throw this.recoverFromSocketFailure(
retryError,
normalizedError.message,
);
}
}
}
private async closeSubscription(subId: string) {
if (this.wsId === null) {
return;
}
await this.sendRaw(["CLOSE", subId]);
}
async publishEvent(
event: RelayEvent,
timeoutMessage: string,
sendErrorMessage: string,
) {
// Await the gate before sending EVENT; op timeout starts after the wait.
await waitForRateLimit();
return new Promise<RelayEvent>((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.pendingEvents.delete(event.id);
reject(new Error(timeoutMessage));
}, PUBLISH_TIMEOUT_MS);
this.pendingEvents.set(event.id, {
event,
resolve,
reject,
timeout,
});
void this.sendRaw(["EVENT", event]).catch(async (error) => {
const pendingEvent = this.pendingEvents.get(event.id);
this.pendingEvents.delete(event.id);
const normalizedError = this.recoverFromSocketFailure(
error,
sendErrorMessage,
);
try {
await this.ensureConnected();
if (!pendingEvent) {
throw normalizedError;
}
this.pendingEvents.set(event.id, pendingEvent);
await this.sendRaw(["EVENT", event]);
} catch (retryError) {
window.clearTimeout(timeout);
this.pendingEvents.delete(event.id);
reject(
this.recoverFromSocketFailure(retryError, normalizedError.message),
);
}
});
});
}
private async handleWsMessage(message: unknown, generation: number) {
if (generation !== this.connectionGeneration) return;
this.stallWatchdog.recordInbound();
if (isWebSocketClose(message)) {
if (isServiceRestartClose(message))
this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
this.resetConnection(new Error("Relay connection closed."));
return;
}
if (isWebSocketError(message)) {
this.resetConnection(new Error("Relay connection errored."));
return;
}
const payload = getTextPayload(message);
if (!payload) {
return;
}
let data: unknown;
try {
data = JSON.parse(payload);
} catch {
return;
}
if (!Array.isArray(data) || data.length === 0) {
return;
}
const [type, ...rest] = data;
if (type === "AUTH" && typeof rest[0] === "string") {
await this.handleAuthChallenge(rest[0], generation);
return;
}
if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
this.handleEvent(rest[0], rest[1] as RelayEvent);
return;
}
if (
type === "OK" &&
typeof rest[0] === "string" &&
typeof rest[1] === "boolean"
) {
this.handleOk(
rest[0],
rest[1],
typeof rest[2] === "string" ? rest[2] : "",
);
return;
}
if (type === "EOSE" && typeof rest[0] === "string") {
this.handleEose(rest[0]);
return;
}
if (type === "CLOSED" && typeof rest[0] === "string") {
handleRelayClosed({
subscriptions: this.subscriptions,
subId: rest[0],
message: typeof rest[1] === "string" ? rest[1] : "",
sendReq: (subId, filter) =>
this.sendRawWithReconnectRetry(
["REQ", subId, filter],
"Failed to restore relay subscription after CLOSED.",
),
});
return;
}
if (type === "NOTICE" && typeof rest[0] === "string") {
const notice: string = rest[0];
// Relay back-pressure — arm the gate until the window expires.
if (notice.startsWith("rate-limited:")) {
activateRateLimit(parseRateLimitHint(notice));
}
}
}
private async handleAuthChallenge(challenge: string, generation: number) {
if (!this.relayUrl) {
this.relayUrl = await getRelayWsUrl();
}
const event = await createAuthEvent({
challenge,
relayUrl: this.relayUrl,
});
if (generation !== this.connectionGeneration || !this.authRequest) {
return;
}
this.authRequest.pendingEventId = event.id;
await this.sendRaw(["AUTH", event]);
}
private handleEvent(subId: string, event: RelayEvent) {
const subscription = this.subscriptions.get(subId);
if (!subscription) {
return;
}
if (subscription.mode === "first") {
subscription.onEvent(event);
return;
}
if (!prepareSubscriptionEvent(subscription, event)) return;
this.eventBuffer.push({ subId, event });
this.flushTimeout ??= window.setTimeout(
() => this.flushEventBuffer(),
EVENT_BATCH_MS,
);
}
private flushEventBuffer() {
this.flushTimeout = null;
const buffer = this.eventBuffer;
this.eventBuffer = [];
// Re-lookup: subscriptions removed during batch window are intentionally skipped.
for (const { subId, event } of buffer) {
const subscription = this.subscriptions.get(subId);
if (subscription?.mode === "live") {
subscription.onEvent(event);
}
}
}
private handleEose(subId: string) {
handleSubscriptionEose({
subscriptions: this.subscriptions,
subId,
closeSubscription: (id) => this.closeSubscription(id),
});
}
private handleOk(eventId: string, success: boolean, message: string) {
if (this.authRequest && this.authRequest.pendingEventId === eventId) {
window.clearTimeout(this.authRequest.timeout);
const authRequest = this.authRequest;
this.authRequest = null;
// Decision table lives in relayAuthPolicy.ts.
const decision = this.authOkTracker.record(success, message);
if (decision === "authenticated") {
authRequest.resolve();
} else {
const error = new Error(message || "Relay authentication rejected.");
authRequest.reject(error);
this.resetConnection(error, { reconnect: decision === "retry" });
}
return;
}
const pendingEvent = this.pendingEvents.get(eventId);
if (!pendingEvent) {
return;
}
window.clearTimeout(pendingEvent.timeout);
this.pendingEvents.delete(eventId);
if (success) {
pendingEvent.resolve(pendingEvent.event);
} else {
pendingEvent.reject(new Error(message || "Relay rejected the event."));
}
}
private hasLiveSubscriptions() {
return [...this.subscriptions.values()].some((s) => s.mode === "live");
}
private async replayLiveSubscriptions() {
const generation = this.connectionGeneration;
try {
await replayLiveSubscriptions({
subscriptions: this.subscriptions,
sendRaw: (payload) => this.sendRaw(payload),
requestHistory: (filter) => this.requestHistory(filter),
visibleChannelId: this.visibleChannelId,
isActive: () => this.connectionGeneration === generation,
});
} catch (error) {
const reconnectError =
error instanceof Error
? error
: new Error("Failed to restore relay subscriptions.");
this.resetConnection(reconnectError);
throw reconnectError;
}
}
private scheduleReconnect() {
if (
!shouldScheduleReconnect({
terminal: this.terminal,
hasPendingReconnect: this.reconnectTimeout !== null,
hasLiveSocket: this.wsId !== null,
keepAliveRequested: this.keepAliveRequested,
hasLiveSubscriptions: this.hasLiveSubscriptions(),
})
) {
return;
}
// ±25% jitter spreads a fleet's AUTH storms across a 50% window instead
// of hitting the relay at the same instant.
const jitter = this.reconnectDelayMs * (0.75 + Math.random() * 0.5);
const delay = Math.min(jitter, RECONNECT_MAX_DELAY_MS);
this.reconnectDelayMs = Math.min(
this.reconnectDelayMs * 2,
RECONNECT_MAX_DELAY_MS,
);
this.reconnectTimeout = window.setTimeout(() => {
this.reconnectTimeout = null;
void this.ensureConnected()
.then(() => this.reconnectWaiters.settle())
.catch((error) => {
this.reconnectWaiters.settle(
this.normalizeRelayError(error, "Relay reconnect failed."),
);
this.scheduleReconnect();
});
}, delay);
}
private emitReconnectIfNeeded() {
const shouldNotifyReconnectListeners =
this.hasConnectedOnce && this.notifyReconnectListeners;
this.hasConnectedOnce = true;
this.notifyReconnectListeners = false;
if (!shouldNotifyReconnectListeners) {
return;
}
for (const listener of this.reconnectListeners) {
try {
listener();
} catch (error) {
console.error("Failed to handle relay reconnect", error);
}
}
}
private resetConnection(
error: Error,
options?: {
reconnect?: boolean;
},
) {
this.onMessageChannel = null;
this.stallWatchdog.stop();
this.connectionGeneration++;
if (this.stabilityTimer !== null) {
window.clearTimeout(this.stabilityTimer);
this.stabilityTimer = null;
}
if (this.flushTimeout !== null) window.clearTimeout(this.flushTimeout);
this.flushTimeout = null;
this.eventBuffer = [];
if (options?.reconnect === false) {
this.terminal = true;
this.connectionStateEmitter.set("disconnected");
} else if (
// A late retry failure racing a terminal latch must not paint
// "reconnecting" over the terminal "disconnected" state; stall is a
// stronger signal than a generic drop and is kept until reconnect.
!this.terminal &&
this.connectionStateEmitter.get() !== "stalled"
) {
this.connectionStateEmitter.set("reconnecting");
}
if (options?.reconnect !== false && this.hasConnectedOnce) {
this.notifyReconnectListeners = true;
}
if (options?.reconnect === false && this.reconnectTimeout) {
window.clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (options?.reconnect === false) {
this.reconnectWaiters.settle(error);
}
if (this.wsId !== null) {
void closeWebSocket(this.wsId, "connection reset");
}
this.wsId = null;
if (this.authRequest) {
window.clearTimeout(this.authRequest.timeout);
this.authRequest.reject(error);
this.authRequest = null;
}
for (const [subId, subscription] of this.subscriptions) {
if (subscription.mode !== "live") {
window.clearTimeout(subscription.timeout);
subscription.reject(error);
this.subscriptions.delete(subId);
continue;
}
subscription.resolveReady?.();
subscription.resolveReady = undefined;
clearClosedRetry(subscription);
}
for (const [eventId, pendingEvent] of this.pendingEvents) {
window.clearTimeout(pendingEvent.timeout);
pendingEvent.reject(error);
this.pendingEvents.delete(eventId);
}
if (options?.reconnect !== false) {
this.scheduleReconnect();
}
}
}