import type { RelayEvent } from "@/shared/api/types"; /** * Observable connection state for the relay singleton. * * - `idle` — never tried to connect yet (post-init, pre-community). * - `connecting` — initial socket + AUTH handshake in flight. * - `connected` — socket open and AUTH'd. * - `reconnecting` — socket dropped, waiting for the backoff timer. * - `stalled` — socket is *open* per the WS layer but no inbound frames * for a long time (half-open socket / VPN split-brain). We * surface this so the UI can warn even though tungstenite * hasn't reported anything wrong yet. * - `disconnected` — final/terminal disconnect (auth rejected, community * switch, etc.) — no auto-reconnect scheduled. */ export type ConnectionState = | "idle" | "connecting" | "connected" | "reconnecting" | "stalled" | "disconnected"; /** True when the UI should surface a "connection lost" indicator. */ export function isRelayConnectionDegraded(state: ConnectionState): boolean { return ( state === "reconnecting" || state === "stalled" || state === "disconnected" ); } /** * Aggregate cap on explicit `#h` channel values the relay accepts across one * REQ's filters before rejecting it as `restricted: too many explicit * channels`. Mirrors `MAX_EXPLICIT_CHANNEL_VALUES` in * `crates/buzz-relay/src/handlers/req.rs`. A subscription over more hidden DMs * than this must be split into multiple REQs, each within the cap. */ export const MAX_EXPLICIT_CHANNEL_VALUES = 128; export type RelaySubscriptionFilter = { ids?: string[]; kinds: number[]; limit: number; authors?: string[]; since?: number; until?: number; /** Relay extension: composite pagination tiebreak paired with `until`. */ before_id?: string; } & Partial>; type HistorySubscription = { mode: "history"; filter: RelaySubscriptionFilter; events: RelayEvent[]; resolve: (events: RelayEvent[]) => void; reject: (error: Error) => void; timeout: number; timeoutMs: number; closedRetryAttempt?: number; }; type FirstEventSubscription = { mode: "first"; onEvent: (event: RelayEvent) => void; resolve: (event: RelayEvent | null) => void; reject: (error: Error) => void; timeout: number; }; export type LiveSubscriptionReadiness = "eose" | "closed" | "timeout"; type LiveSubscription = { mode: "live"; filter: RelaySubscriptionFilter; /** Client-side admission only; interactive consumers still obey cooldown/pacing. */ priority?: "interactive"; onEvent: (event: RelayEvent) => void; resolveReady?: (readiness: LiveSubscriptionReadiness) => void; /** Release readiness/cancellation listeners when this entry is retired. */ onRemoved?: () => void; lastSeenCreatedAt?: number; /** Connection generation on which this subscription last reached EOSE. */ eoseGeneration?: number; /** * Unix seconds up to which this subscription was registered on a connection * that delivered its EOSE: that connection's last inbound frame, stamped on * close. Not proof of delivery; see `relayResumeCursor.ts`. */ liveUntil?: number; /** * Lower bound of a reconnect backfill window that has not yet completed. * * Events on the restored live REQ advance `lastSeenCreatedAt` regardless of * backfill success, so after an exhausted backfill the cursor alone would * make the next reconnect skip the unresolved older window — silent message * loss. This floor is pinned when paging starts and cleared only when a * backfill pass completes; the next replay starts from * `min(pendingReplaySince, cursor window)`. */ pendingReplaySince?: number; /** Dispatch-level duplicate suppression shared by restored-live and repair. */ reconnectReplay?: { generation: number; seenEventIds: Set; liveEose: boolean; repairDone: boolean; /** When the restored live REQ went out; its EOSE deadline runs from here. */ liveReqSentAtMs?: number; /** * Set when the history fallback follows an unacknowledged live REQ: a late * original EOSE is then indistinguishable from the fallback's, so no EOSE * ends dedupe; only the fallback's deadline or teardown does. */ ignoreEose?: boolean; /** * Wakes the repair worker waiting for the restored live REQ's EOSE: on * EOSE, or when a newer replay supersedes this one. */ liveEoseWaiter?: () => void; }; closedRetryAttempt?: number; closedRetryTimeout?: number; }; export type PendingEvent = { event: RelayEvent; resolve: (event: RelayEvent) => void; reject: (error: Error) => void; timeout: number; }; export type RelaySubscription = | HistorySubscription | FirstEventSubscription | LiveSubscription; export type SubscriptionEventBufferItem = { subId: string; event: RelayEvent; generation: number; }; export function sortEvents(events: RelayEvent[]) { return [...events].sort((left, right) => { if (left.created_at !== right.created_at) { return left.created_at - right.created_at; } // Same (created_at, id) tiebreak as the cache sort (sortMessages) so a // history REQ resolves same-second events in a stable, relay-matching // order. Currently every consumer re-sorts downstream, but keeping the // two sorts on one invariant avoids a latent ordering drift. return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; }); } /** * Splits a native websocket delivery into individual frames. * * The native plugin coalesces inbound frames into one IPC delivery, so a * delivery is an array of frames. Older single-frame deliveries stay valid: * `getTextPayload` rejects arrays, so every consumer must unwrap here rather * than per-client — an un-unwrapped consumer drops batched frames silently * instead of failing. */ export function toRelayFrames(message: unknown): unknown[] { return Array.isArray(message) ? message : [message]; } export function getTextPayload(message: unknown) { if (typeof message === "string") { return message; } if ( typeof message === "object" && message !== null && "type" in message && message.type === "Text" && "data" in message && typeof message.data === "string" ) { return message.data; } if ( typeof message === "object" && message !== null && "Text" in message && typeof message.Text === "string" ) { return message.Text; } return null; }