Files
buzz/desktop/src/shared/api/relayClientSession.ts
Will Pfleger 6f6093243e fix(desktop): harden profile batch and thread-reply fetches against relay slowness (#7188)
Three client gaps turn transient relay failures into permanent UI
degradation. Under a slow or rate-limited relay:

1. A cold channel's profile batch exhausts its single retry and leaves
raw npubs + broken mention chips until the user manually kicks the
channel.
2. A thread opened from a notification trusts a successful-but-empty
reply read as authoritative and never retries.
3. A rate-limited `CLOSED` on a history subscription immediately rejects
the caller rather than retrying after the rate-limit window.

All three are addressed without changing global query defaults or the
happy-path behavior.

## Changes

**Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`,
`desktop/src/features/profile/hooks.ts`)

Override `retry: 3` with exponential backoff and error-gated
`refetchOnWindowFocus: (query) => query.state.status === "error"`,
scoped to this query only. The global defaults (`retry: 1`,
`refetchOnWindowFocus: false`) are intentional for other queries and are
unchanged. After the retry budget exhausts, a window-focus event (e.g.
channel-switch) recovers the query automatically — but only when it is
already in an error state, preventing unnecessary refetches for
successful batches.

**Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`,
`ChannelScreen.tsx`)

Add optional `expectedEventId` parameter. When a completed paged fetch
does not contain the expected event, throw
`ThreadExpectedEventMissingError` so React Query's built-in retry
machinery handles it rather than caching an authoritative empty.
`ChannelScreen` passes `threadScrollTargetId` (the notification-linked
reply ID) as `expectedEventId`.

When notification routing changes `expectedEventId` while the same
thread root is already mounted (same query key), an explicit
`invalidateQueries` in a `useEffect` triggers a fresh validation pass.
The `useEffect` is declared after `useQuery` so TanStack's internal
options-update effect installs the new `queryFn` closure first; the
refetch therefore uses the current `expectedEventId` rather than the
previous null closure. For the cold-start race (target arrives before
the first page returns), the effect detects `fetchStatus === "fetching"
&& status === "pending"` and calls
`cancelQueries().then(invalidateQueries)` so the obsolete in-flight
response cannot settle as authoritative before the new target's
validation closure is active.

The query-fn tracks consecutive fetch attempts per target. On attempt 3,
it adds the target to `exhaustedTargetsRef` before calling
`loadThreadReplies`. `loadThreadReplies` sees the target in the
exhausted set and returns the fetched replies directly rather than
throwing — the terminal attempt always resolves to success. No
re-entrant scheduling: the resolution is synchronous inside the query
function itself. Deleted/moderated targets never lock the thread in a
terminal error surface.

**Fix 3 — CLOSED recovery for history subscriptions**
(`relayClosedRecovery.ts`, `relayClientSession.ts`,
`relayClientShared.ts`, `relayGateBoundary.ts`)

On a rate-limited `CLOSED` the subscription previously rejected the
caller immediately. Store `filter` and `timeoutMs` on
`HistorySubscription`, then on rate-limited `CLOSED` re-register under a
fresh `subId` and defer `sendReq` until the rate-limit window clears —
matching the live-sub recovery design already present in
`relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries
reject immediately so callers are never left waiting indefinitely. A new
op-timeout guards the retry REQ against a non-responding relay; when the
op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the
behavior of the original timeout path) so the relay releases the slot
rather than counting it against the per-connection cap.

## Tests

- `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for
history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send +
late-EOSE non-regression, rejecting-`closeSubscription` swallowed
without unhandled rejection, wiring source assertion (fails if
`relayClientSession.ts` drops the `closeSubscription` callback) — 18
tests
- `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests
(throw/exhaustion-guard); behavioral hook tests via real
`QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data,
settled null→target change retries on missing-target page and lands
target data (fails if `invalidateQueries` is removed OR if `useQuery` is
moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated
fetcher — released after rerender, stale empty discarded, replacement
fetch settles with target); ChannelScreen wiring source assertion — 9
tests
- `profileBatchResilience.test.mjs`: source assertions for `retry: 3`,
`retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global
defaults — 2 tests

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-02 17:42:45 -04:00

1071 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,
toRelayFrames,
type ConnectionState,
type LiveSubscriptionReadiness,
type PendingEvent,
type RelaySubscription,
type RelaySubscriptionFilter,
type SubscriptionEventBufferItem,
} from "@/shared/api/relayClientShared";
import {
buildChannelAuxDeletionFilter,
buildChannelFilter,
buildChannelHistoryFilter,
buildChannelMentionFilter,
buildGlobalStreamFilter,
} from "@/shared/api/relayChannelFilters";
import {
clearClosedRetry,
flushEvents,
handleRelayClosed,
handleSubscriptionEose,
prepareSubscriptionEvent,
} from "@/shared/api/relayClosedRecovery";
import { getChannelReconnectRepairEvents } from "@/shared/api/channelReconnectRepair";
import { replayLiveSubscriptions } from "@/shared/api/relayReconnectReplay";
import { publishSessionEvent } from "@/shared/api/relayEventPublisher";
import { activateRateLimitIfSignalled } 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,
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 {
armRelayAuthentication,
AuthOkTracker,
type RelayAuthRequest,
} from "@/shared/api/relayAuthPolicy";
import { createRelayInboundBuffer } from "@/shared/api/relayInboundBuffer";
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
export class RelayClient {
private wsId: number | null = null;
private relayUrl: string | null = null;
private connectPromise: Promise<number> | null = null;
private reconnectTimeout: number | null = null;
private reconnectWaiters = new RelayReconnectWaiters();
private reconnectDelayMs = RECONNECT_BASE_DELAY_MS;
private keepAliveRequested = false;
private authRequest: RelayAuthRequest | null = null;
private subscriptions = new Map<string, RelaySubscription>();
private pendingEvents = new Map<string, PendingEvent>();
private eventBuffer: SubscriptionEventBufferItem[] = [];
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 sessionEpoch = 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.sessionEpoch++;
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 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,
onReady?: (readiness: LiveSubscriptionReadiness) => void,
readinessTimeoutMs?: number,
) {
return this.subscribe(filter, onEvent, onReady, readinessTimeoutMs);
}
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 this.connectionGeneration;
}
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().then(() => this.connectionGeneration);
}
const connectPromise = this.connect();
this.connectPromise = connectPromise;
try {
return 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;
const inbound = createRelayInboundBuffer(
async (delivery) => {
for (const message of toRelayFrames(delivery))
await this.handleWsMessage(message, generation);
},
(error) => {
if (generation === this.connectionGeneration)
this.recoverFromSocketFailure(error, "Relay connection errored.");
},
);
this.onMessageChannel = new Channel<unknown>((delivery) =>
inbound.receive(delivery),
);
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;
const authentication = armRelayAuthentication(
AUTH_TIMEOUT_MS,
(request) => {
this.authRequest = request;
},
(error) => {
this.authRequest = null;
this.resetConnection(error);
},
);
const drain = inbound.drain();
await Promise.race([drain, authentication, inbound.overflow]);
await drain;
await authentication;
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();
return generation;
} 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,
onReady?: (readiness: LiveSubscriptionReadiness) => void,
readinessTimeoutMs = 250,
) {
await this.ensureConnected();
const subId = `live-${crypto.randomUUID()}`;
let resolveReady = (_readiness: LiveSubscriptionReadiness) => {};
const ready = new Promise<void>((resolve) => {
resolveReady = (readiness) => {
window.clearTimeout(fallbackTimeout);
onReady?.(readiness);
resolve();
};
});
const fallbackTimeout = window.setTimeout(
() => resolveReady("timeout"),
readinessTimeoutMs,
);
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 async sendRawForGeneration(payload: unknown[], generation: number) {
if (generation !== this.connectionGeneration || this.wsId === null) {
throw new Error("Relay publish was superseded by a session change.");
}
const wsId = this.wsId;
await invoke("plugin:websocket|send", {
id: 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,
) {
return publishSessionEvent(
{
generation: () => this.connectionGeneration,
ownership: () => this.sessionEpoch,
pendingEvents: this.pendingEvents,
send: (payload, generation) =>
this.sendRawForGeneration(payload, generation),
reconnect: () => this.ensureConnected(),
normalizeError: (error, fallback) =>
this.normalizeRelayError(error, fallback),
recoverSocketFailure: (error, fallback) =>
this.recoverFromSocketFailure(error, fallback),
},
event,
timeoutMessage,
sendErrorMessage,
);
}
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, generation);
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], generation);
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.",
),
closeSubscription: (subId) => this.closeSubscription(subId),
});
return;
}
if (type === "NOTICE" && typeof rest[0] === "string") {
// Connection-scoped back-pressure — arm the gate until it expires.
activateRateLimitIfSignalled(rest[0]);
}
}
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, generation: number) {
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, generation });
this.flushTimeout ??= window.setTimeout(
() => this.flushEventBuffer(),
EVENT_BATCH_MS,
);
}
private flushEventBuffer() {
this.flushTimeout = null;
const buffer = this.eventBuffer;
this.eventBuffer = [];
flushEvents(buffer, this.subscriptions, this.connectionGeneration);
}
private handleEose(subId: string, generation: number) {
this.flushEventBuffer(); // Deliver preceding EVENT frames before EOSE.
handleSubscriptionEose({
subscriptions: this.subscriptions,
subId,
closeSubscription: (id) => this.closeSubscription(id),
generation,
});
}
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 {
// Back-pressure now arrives here rather than as a NOTICE: the relay
// rejects an over-quota EVENT on the OK channel so this pending publish
// can be settled at all. Unarmed, the send retries into the same quota.
activateRateLimitIfSignalled(message);
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),
requestRepair: getChannelReconnectRepairEvents,
generation,
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?.("closed");
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();
}
}
}