{"release_id":"01a1998d-9802-0000-b0f7-33a23307f4ed","chunk_id":"01a1998d-981d-7c10-a705-c6e662141b4e","file":"BJubW__C.mjs","mappings":"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","names":["LAST_SEEN_KEY","channel","HIDDEN_CLOSE_DELAY_MS","UNSUBSCRIBE_CLOSE_DELAY_MS","DEDUP_MAX","DEDUP_TRIM_TO","RECONNECT_GRACE_MS","RECOVER_BASE_MS","RECOVER_MAX_MS","RECOVER_HARD_RESET_MS","OFFLINE_VERIFY_MS","RealtimeClient","writable","channelName","handler","entry","cur","createSupabaseBrowserClient","sb","stale","c","ch","raw","evt","status","next","prev","healthyCount","e","path","live","kinds","entries","trackRealtimeConnectionState","degradedMs","hiddenMs","now","delay","hidden","offline","token","degradedFor","sinceHardReset","data","arr","h","err","seq","promise","url","r","type","payload","v","browser","realtime","connectionState"],"sources":["../../../../../../src/lib/realtime/client.ts"],"sourcesContent":["/** RAT-357 (F1): the RealtimeClient primitive.\n *\n *  Every realtime subscription site on the app eventually flows through this\n *  module. It owns the single Supabase Realtime socket per tab (already\n *  cached in `$lib/supabase`), multiplexes logical channels on top of it,\n *  tracks last-seen sequence numbers per channel so reconnect → replay\n *  fills the gap, and reacts to the mobile-specific events (visibility,\n *  online/offline) the SDK doesn't handle on its own.\n *\n *  This file is intentionally transport-agnostic past the SDK type imports:\n *  if we ever swap to PartyKit / Durable Objects, only `openChannel` /\n *  `closeChannel` need to change.\n *\n *  See RAT-355 (epic) for the full plan; F2 adds the server outbox + the\n *  events these channels actually carry, F3 adds the replay endpoint this\n *  client calls (`/api/realtime/replay`), F4 migrates the existing per-route\n *  subs onto this primitive, F5 paints the connection-state indicator. */\n\nimport { browser, dev } from '$app/environment';\nimport { writable, type Writable } from 'svelte/store';\nimport type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js';\nimport { createSupabaseBrowserClient } from '$lib/supabase';\nimport { trackRealtimeConnectionState } from '$lib/analytics';\nimport type { ChannelHandler, ConnectionState, RealtimeEvent } from './types';\n\nconst LAST_SEEN_KEY = (channel: string) => `realtime:lastSeen:${channel}`;\n/** How long after the tab goes hidden before we tear down the socket. iOS\n *  Safari kills it on its own within ~10s; doing it ourselves first means\n *  the SDK's internal state matches reality, so reconnect on visible is\n *  one clean code path instead of \"recover from a half-dead socket\". */\nconst HIDDEN_CLOSE_DELAY_MS = 5_000;\n/** RAT-370: how long to keep a channel warm after its last handler unsubscribes.\n *  Ordinary SvelteKit navigation unmounts a component (last unsub) then mounts\n *  the next route's subscriber a tick later. Closing the SDK channel synchronously\n *  on that last unsub tore the socket channel down and reopened it on every route\n *  change — each reopen cycling through CLOSED → CONNECTING → SUBSCRIBED, which\n *  armed the RECONNECT_GRACE_MS timer and, on a slow reopen, painted the\n *  \"reconnecting…\" pill for no real reason. Deferring the close (same pattern as\n *  the tab-hidden delay above) lets a re-subscribe within the window reuse the\n *  still-open channel, so route changes stay invisible to the indicator. */\nconst UNSUBSCRIBE_CLOSE_DELAY_MS = 5_000;\n/** Cap on the per-channel dedup set. ~500 events covers a generous burst\n *  (chat-thread under spam) without growing unbounded. We trim back to 400\n *  on overflow so we don't churn on every event past the threshold. */\nconst DEDUP_MAX = 500;\nconst DEDUP_TRIM_TO = 400;\n\ninterface ChannelEntry {\n\tname: string;\n\tchannel: RealtimeChannel | null;\n\thandlers: Set<ChannelHandler>;\n\t/** Largest `seq` we've ever delivered to a handler on this channel. Survives\n\t *  tab restart via localStorage so a returning user picks up exactly where\n\t *  they left off. 0 means \"first-ever subscribe, no history to replay\". */\n\tlastSeenSeq: number;\n\t/** Recent event ids for socket↔replay overlap dedup. Insertion-ordered so\n\t *  trim drops the oldest entries first. */\n\tseenIds: Set<string>;\n\t/** RAT-369: per-channel health, drives the global connectionState the F5\n\t *  indicator paints from. The naive \"flip global state on every SDK\n\t *  status event\" approach trashed: with multiple channels open, a stale\n\t *  CHANNEL_ERROR from a closing channel would mark the whole client\n\t *  reconnecting even though the live channels were already SUBSCRIBED.\n\t *  Now we track each one and derive global = healthy if any is. */\n\thealthy: boolean;\n\t/** In-flight replay dedup. A fresh channel open fires replay() twice in\n\t *  quick succession — eagerly in subscribe() and again from the SUBSCRIBED\n\t *  status callback — both with the same `sinceSeq` (no events delivered in\n\t *  between to advance it). Visibility-regain can pile on more. Collapsing\n\t *  identical concurrent replays into one shared promise kills the redundant\n\t *  /api/realtime/replay invocations (a top Function Duration contributor)\n\t *  with zero correctness change: same sinceSeq → same outbox response. */\n\treplayInFlight: { seq: number; promise: Promise<void> } | null;\n\t/** RAT-370: pending deferred-close timer. Set when the last handler\n\t *  unsubscribes; cleared (channel kept warm) if a new handler subscribes\n\t *  before it fires. Only when it fires with no handlers left do we actually\n\t *  close + drop the channel. */\n\tcloseTimer: ReturnType<typeof setTimeout> | null;\n}\n\n/** How long to wait before declaring the client unhealthy after the last\n *  channel went unhealthy. Lets short reconnects (a channel cycling through\n *  CLOSED → CONNECTING → SUBSCRIBED on resubscribe) stay invisible to the\n *  indicator — only sustained outages paint the pill. */\nconst RECONNECT_GRACE_MS = 2_500;\n\n/** RAT-458: active reconnect recovery. Once the client is fully degraded and\n *  the pill is up, we can't just leave the SDK to reconnect on its own — on an\n *  expired / stale JWT it retries the join with the *same* token indefinitely\n *  and never comes back (the multi-minute \"reconnecting…\" stalls users hit on\n *  /b and /c). So we drive recovery ourselves: mint a fresh token, push it to\n *  the socket with `setAuth`, and force-cycle the errored channels so they\n *  rejoin clean instead of spinning on the stale token. Retried with capped\n *  exponential backoff until a channel comes back healthy (or we go offline /\n *  the tab hides — both stop the loop; onOnline / onVisible take over).\n *\n *  RAT-458 follow-up: two hardening changes after we still measured multi-minute\n *  reconnecting windows in prod.\n *   1. The base backoff was 3s, so the first rejoin didn't fire until grace\n *      (2.5s) + 3s ≈ 5.5s after a channel errored. Dropped to 1s so recovery\n *      starts fast; the exponential curve still backs off on a persistent\n *      outage.\n *   2. Channel-only cycling can't heal a *wedged socket*. `channel.subscribe()`\n *      reconnects the websocket only when `socket.isConnected()` is false, and a\n *      half-dead socket (readyState still OPEN, heartbeat silently dropped)\n *      reports connected — so every reopened channel's JOIN times out on the\n *      same dead socket and we spin forever. Once a degraded window crosses\n *      RECOVER_HARD_RESET_MS we escalate to a full socket teardown\n *      (`realtime.disconnect()` → reopen), which is the only thing that forces a\n *      brand-new websocket. */\nconst RECOVER_BASE_MS = 1_000;\nconst RECOVER_MAX_MS = 30_000;\n/** Once the pill has been up this long without a channel coming back, stop\n *  reopening channels on the (likely wedged) socket and tear the whole socket\n *  down for a clean rebuild. Lowered from 30s: channel-only cycling can't heal a\n *  wedged socket, so waiting 30s to escalate just prolonged the outage. Measured\n *  against `degradedSince` (how long the pill has been up), NOT a per-loop clock —\n *  see the recovery-loop state fields below. */\nconst RECOVER_HARD_RESET_MS = 10_000;\n\n/** How long to wait before believing a `navigator` offline event that arrived\n *  while a channel still looked healthy. Long enough to ride out a mobile\n *  Safari network handoff, far shorter than the ~50s it takes a missed\n *  heartbeat to reveal a genuine disconnect. */\nconst OFFLINE_VERIFY_MS = 4_000;\n\nclass RealtimeClient {\n\t/** Reactive — components subscribe via `$connectionState`. The connection\n\t *  indicator (F5) reads this exclusively. */\n\treadonly connectionState: Writable<ConnectionState> = writable('connected');\n\treadonly lastConnectedAt: Writable<Date | null> = writable(null);\n\n\tprivate readonly channels = new Map<string, ChannelEntry>();\n\tprivate supabase: SupabaseClient | null = null;\n\tprivate initialized = false;\n\tprivate hiddenCloseTimer: ReturnType<typeof setTimeout> | null = null;\n\t/** RAT-369: pending grace-period timer for the \"we might be reconnecting\"\n\t *  transition. Set when a channel becomes unhealthy + no other channel\n\t *  is currently SUBSCRIBED; cleared the moment any channel comes back. */\n\tprivate unhealthyGraceTimer: ReturnType<typeof setTimeout> | null = null;\n\t/** RAT-370: last state we captured a transition for, so setConnectionState\n\t *  only emits analytics on genuine edges (SUBSCRIBED fires markChannelHealthy\n\t *  on every channel — we don't want a capture per channel). */\n\tprivate lastCapturedState: ConnectionState = 'connected';\n\t/** RAT-370: wall-clock ms when we first went degraded (reconnecting/offline),\n\t *  so the recovery capture can report how long the degraded window lasted. */\n\tprivate degradedSince: number | null = null;\n\t/** Background time inside the current degraded window, and when the tab last\n\t *  went hidden (null while visible). The pill can't be seen from the\n\t *  background, yet a window that spanned it used to report all of it: a 2s\n\t *  reconnect on return read as a minute-long outage in `degraded_ms`, which\n\t *  inflated the very metric the reconnect fixes were judged by. */\n\tprivate degradedHiddenMs = 0;\n\tprivate hiddenAt: number | null = null;\n\t/** RAT-458: active-recovery loop state. `recovering` is the single guard for\n\t *  \"the recovery loop owns retry cadence right now\" — it stays true across the\n\t *  async gap in recoverNow() (when recoverTimer is briefly null), so a channel\n\t *  erroring mid-pass can't spin up a competing grace timer. */\n\tprivate recovering = false;\n\tprivate recoverTimer: ReturnType<typeof setTimeout> | null = null;\n\tprivate recoverAttempts = 0;\n\t/** Wall-clock ms of the last hard socket reset in the current degraded window\n\t *  (null = none yet). The escalation *clock* is `degradedSince` — a stable\n\t *  measure of how long the pill has been up that, unlike the old per-loop\n\t *  `recoveringSince`, is NOT reset every time a spurious online/offline event\n\t *  restarts the recovery loop (mobile Safari fires those in bursts during a\n\t *  network handoff, so the old clock never accumulated enough to fire the hard\n\t *  reset). `lastHardResetAt` only rate-limits repeat resets to one per threshold\n\t *  window so we don't tear the socket down on every pass once past it. */\n\tprivate lastHardResetAt: number | null = null;\n\tprivate offlineVerifyTimer: ReturnType<typeof setTimeout> | null = null;\n\n\t/** Subscribe to a logical channel. Returns an unsubscribe fn.\n\t *\n\t *  Reentrant: calling `subscribe('user:abc', h)` twice attaches both\n\t *  handlers to the same underlying SDK channel (refcount); the SDK\n\t *  channel closes only when the last handler unsubs. */\n\tsubscribe<T = Record<string, unknown>>(\n\t\tchannelName: string,\n\t\thandler: ChannelHandler<T>\n\t): () => void {\n\t\tif (!browser) return () => {};\n\t\tthis.ensureInitialized();\n\n\t\tlet entry = this.channels.get(channelName);\n\t\tif (!entry) {\n\t\t\tentry = {\n\t\t\t\tname: channelName,\n\t\t\t\tchannel: null,\n\t\t\t\thandlers: new Set(),\n\t\t\t\tlastSeenSeq: this.readLastSeen(channelName),\n\t\t\t\tseenIds: new Set(),\n\t\t\t\thealthy: false,\n\t\t\t\treplayInFlight: null,\n\t\t\t\tcloseTimer: null\n\t\t\t};\n\t\t\tthis.channels.set(channelName, entry);\n\t\t}\n\t\tentry.handlers.add(handler as ChannelHandler);\n\t\t// RAT-370: re-subscribe within the deferred-close window → keep the warm\n\t\t// channel, cancel the pending teardown. This is the navigation-churn case.\n\t\tif (entry.closeTimer) {\n\t\t\tclearTimeout(entry.closeTimer);\n\t\t\tentry.closeTimer = null;\n\t\t}\n\t\t// The warm channel may have rejoined while nothing listened, and that\n\t\t// couldn't clear the pill (markChannelHealthy). The listener that relies\n\t\t// on it now does.\n\t\tif (entry.healthy && this.lastCapturedState !== 'connected') this.markChannelHealthy(entry);\n\n\t\tif (!entry.channel) this.openChannel(entry);\n\t\t// Best-effort replay on first subscribe — covers the case where the page\n\t\t// loaded after some events were already on the wire. No-op if F3 not yet\n\t\t// deployed (404 → swallowed).\n\t\tvoid this.replay(entry);\n\n\t\treturn () => {\n\t\t\tconst cur = this.channels.get(channelName);\n\t\t\tif (!cur) return;\n\t\t\tcur.handlers.delete(handler as ChannelHandler);\n\t\t\tif (cur.handlers.size === 0) this.scheduleClose(cur);\n\t\t};\n\t}\n\n\t/** RAT-370: defer the actual teardown so routine navigation (unmount → remount\n\t *  a tick later) doesn't tear the socket channel down and reopen it. If a new\n\t *  handler subscribes before the timer fires, subscribe() cancels it and the\n\t *  channel is reused warm. */\n\tprivate scheduleClose(entry: ChannelEntry) {\n\t\tif (entry.closeTimer) return;\n\t\tentry.closeTimer = setTimeout(() => {\n\t\t\tentry.closeTimer = null;\n\t\t\t// A late subscribe may have re-populated handlers — if so, stay open.\n\t\t\tif (entry.handlers.size > 0) return;\n\t\t\tthis.closeChannel(entry);\n\t\t\tthis.channels.delete(entry.name);\n\t\t}, UNSUBSCRIBE_CLOSE_DELAY_MS);\n\t}\n\n\t/** Tear everything down. Currently only used by tests; the singleton\n\t *  otherwise lives for the tab's lifetime. */\n\tdispose() {\n\t\tfor (const entry of this.channels.values()) {\n\t\t\tif (entry.closeTimer) clearTimeout(entry.closeTimer);\n\t\t\tentry.closeTimer = null;\n\t\t\tthis.closeChannel(entry);\n\t\t}\n\t\tthis.channels.clear();\n\t\tif (this.hiddenCloseTimer) clearTimeout(this.hiddenCloseTimer);\n\t\tthis.hiddenCloseTimer = null;\n\t\tif (this.unhealthyGraceTimer) clearTimeout(this.unhealthyGraceTimer);\n\t\tthis.unhealthyGraceTimer = null;\n\t\tthis.stopRecovery();\n\t}\n\n\t// ─── internals ────────────────────────────────────────────────────────\n\n\tprivate getSupabase(): SupabaseClient | null {\n\t\tif (!browser) return null;\n\t\tif (!this.supabase) this.supabase = createSupabaseBrowserClient() as SupabaseClient;\n\t\treturn this.supabase;\n\t}\n\n\tprivate openChannel(entry: ChannelEntry) {\n\t\tconst sb = this.getSupabase();\n\t\tif (!sb) return;\n\n\t\t// Defensive: same guard duelRealtime.ts uses. Svelte 5 dev mode double-\n\t\t// invokes effects, and our own visibility handler closes+reopens the\n\t\t// channel. supabase.channel(name) returns the cached channel for an\n\t\t// existing name, and re-running .on() on it after subscribe throws.\n\t\t// Drop any stale channel under this name before creating a new one.\n\t\tconst stale = sb.getChannels().find((c) => c.topic === `realtime:${entry.name}`);\n\t\tif (stale) sb.removeChannel(stale);\n\n\t\tconst ch = sb.channel(entry.name);\n\t\t// Wildcard broadcast event subscribe — F2's outbox emits events typed\n\t\t// by `type` in the payload, but we deliver them all through the same\n\t\t// handler so subscribers branch on `event.type` themselves.\n\t\tch.on('broadcast', { event: '*' }, (raw) => {\n\t\t\t// A frame just arrived over the socket → this channel is definitively live\n\t\t\t// right now. If we're still painting a degraded state (e.g. a spurious\n\t\t\t// offline flipped us with no fresh SUBSCRIBED coming to clear it), treat the\n\t\t\t// frame as the missing recovery edge so the pill clears instead of sticking\n\t\t\t// until a reload. Cheap: markChannelHealthy is skipped once already healthy\n\t\t\t// and connected.\n\t\t\tif (this.lastCapturedState !== 'connected' || !entry.healthy) {\n\t\t\t\tthis.markChannelHealthy(entry);\n\t\t\t}\n\t\t\tconst evt = raw.payload as RealtimeEvent | undefined;\n\t\t\tif (!evt || !evt.id || typeof evt.seq !== 'number') {\n\t\t\t\tif (dev) console.warn('[realtime] dropping malformed event', { channel: entry.name, raw });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.deliver(entry, evt);\n\t\t});\n\t\tch.subscribe((status) => {\n\t\t\tif (dev) console.debug('[realtime]', entry.name, '→', status);\n\t\t\tif (status === 'SUBSCRIBED') {\n\t\t\t\tthis.markChannelHealthy(entry);\n\t\t\t\t// Re-subscribe = potentially missed events while the socket was\n\t\t\t\t// down. Pull from the outbox.\n\t\t\t\tvoid this.replay(entry);\n\t\t\t} else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') {\n\t\t\t\tthis.markChannelUnhealthy(entry);\n\t\t\t}\n\t\t});\n\t\tentry.channel = ch;\n\t}\n\n\tprivate closeChannel(entry: ChannelEntry) {\n\t\tconst sb = this.getSupabase();\n\t\tif (entry.channel && sb) {\n\t\t\tentry.channel.unsubscribe();\n\t\t\tsb.removeChannel(entry.channel);\n\t\t}\n\t\tentry.channel = null;\n\t\t// Don't flip the indicator off a deliberate close — the channel is\n\t\t// gone, not unhealthy. Just drop it out of the health tally.\n\t\tentry.healthy = false;\n\t}\n\n\t/** RAT-369: channel went SUBSCRIBED. Cancel any pending unhealthy grace\n\t *  timer and flip the global state to 'connected'. */\n\tprivate markChannelHealthy(entry: ChannelEntry) {\n\t\tentry.healthy = true;\n\t\t// Kept warm with no listener left (scheduleClose): it says nothing about\n\t\t// the page on screen, so it can't clear the pill either.\n\t\tif (entry.handlers.size === 0) return;\n\t\tif (this.unhealthyGraceTimer) {\n\t\t\tclearTimeout(this.unhealthyGraceTimer);\n\t\t\tthis.unhealthyGraceTimer = null;\n\t\t}\n\t\t// RAT-458: a channel came back — tear down the active-recovery loop.\n\t\tthis.stopRecovery();\n\t\tthis.setConnectionState('connected');\n\t\tthis.lastConnectedAt.set(new Date());\n\t}\n\n\t/** RAT-370: single funnel for connectionState writes. Sets the store and, on\n\t *  a genuine state edge, captures it to PostHog so degraded windows are\n\t *  measurable in analytics (not just visible in session recordings). On\n\t *  recovery it reports how long the degraded window lasted. */\n\tprivate setConnectionState(next: ConnectionState) {\n\t\tthis.connectionState.set(next);\n\t\tconst prev = this.lastCapturedState;\n\t\tif (next === prev) return;\n\t\tthis.lastCapturedState = next;\n\n\t\tlet healthyCount = 0;\n\t\tfor (const e of this.channels.values()) if (e.healthy) healthyCount++;\n\t\tconst path = typeof location !== 'undefined' && location ? location.pathname : null;\n\t\t// Which kinds of channel the page was relying on, and which of them were\n\t\t// down — `path` alone couldn't say what stalled, so no fix could be aimed.\n\t\tconst live = this.liveChannels();\n\t\tconst kinds = (entries: ChannelEntry[]) =>\n\t\t\t[...new Set(entries.map((e) => e.name.split(':')[0]))].sort().join(',') || null;\n\n\t\tif (next === 'reconnecting' || next === 'offline') {\n\t\t\tif (this.degradedSince === null) {\n\t\t\t\tthis.degradedSince = Date.now();\n\t\t\t\tthis.degradedHiddenMs = 0;\n\t\t\t}\n\t\t\ttrackRealtimeConnectionState({\n\t\t\t\tstate: next,\n\t\t\t\tfrom: prev,\n\t\t\t\tchannelCount: this.channels.size,\n\t\t\t\thealthyCount,\n\t\t\t\tchannelKinds: kinds(live),\n\t\t\t\tunhealthyKinds: kinds(live.filter((e) => !e.healthy)),\n\t\t\t\tpath\n\t\t\t});\n\t\t} else {\n\t\t\t// next === 'connected': recovered. Report how long the pill could have\n\t\t\t// been seen: the degraded window minus the time the tab spent hidden.\n\t\t\tlet degradedMs: number | null = null;\n\t\t\tlet hiddenMs: number | null = null;\n\t\t\tif (this.degradedSince !== null) {\n\t\t\t\tconst now = Date.now();\n\t\t\t\thiddenMs =\n\t\t\t\t\tthis.degradedHiddenMs +\n\t\t\t\t\t(this.hiddenAt !== null ? now - Math.max(this.hiddenAt, this.degradedSince) : 0);\n\t\t\t\tdegradedMs = Math.max(0, now - this.degradedSince - hiddenMs);\n\t\t\t}\n\t\t\tthis.degradedSince = null;\n\t\t\tthis.degradedHiddenMs = 0;\n\t\t\t// A real recovery is the only thing that resets the hard-reset limiter —\n\t\t\t// see stopRecovery for why it can't be cleared on a flap.\n\t\t\tthis.lastHardResetAt = null;\n\t\t\ttrackRealtimeConnectionState({\n\t\t\t\tstate: next,\n\t\t\t\tfrom: prev,\n\t\t\t\tchannelCount: this.channels.size,\n\t\t\t\thealthyCount,\n\t\t\t\tdegradedMs,\n\t\t\t\thiddenMs,\n\t\t\t\tpath\n\t\t\t});\n\t\t}\n\t}\n\n\t/** RAT-369: channel went CLOSED / CHANNEL_ERROR / TIMED_OUT. Only flip\n\t *  the global state if NO other channel is currently healthy AND the\n\t *  unhealthy state persists past the grace window — that suppresses\n\t *  the indicator on routine resubscribe churn. */\n\tprivate markChannelUnhealthy(entry: ChannelEntry) {\n\t\tentry.healthy = false;\n\t\t// A channel on its way out (no listener left, scheduleClose pending) isn't\n\t\t// something the page relies on — it used to raise the pill over a healthy\n\t\t// DM or an HTTP-only roleplay chat.\n\t\tif (entry.handlers.size === 0) return;\n\t\tif (this.unhealthyGraceTimer) return; // grace already pending\n\t\t// RAT-458: the recovery loop already owns the retry cadence. Don't re-arm\n\t\t// the grace timer — the errored reopens the loop itself triggers would\n\t\t// otherwise reset the backoff and spin up a competing timer.\n\t\tif (this.recovering) return;\n\t\tthis.unhealthyGraceTimer = setTimeout(() => {\n\t\t\tthis.unhealthyGraceTimer = null;\n\t\t\t// Re-evaluate the channels a component still listens on: nothing left to\n\t\t\t// rely on, or any of them healthy, means there is nothing to report.\n\t\t\tconst live = this.liveChannels();\n\t\t\tif (live.length === 0 || live.some((e) => e.healthy)) {\n\t\t\t\tthis.setConnectionState('connected');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (typeof navigator !== 'undefined' && !navigator.onLine) {\n\t\t\t\tthis.setConnectionState('offline');\n\t\t\t\t// No point recovering while the OS says we're offline — onOnline\n\t\t\t\t// force-cycles everything the moment the network is back.\n\t\t\t} else {\n\t\t\t\tthis.setConnectionState('reconnecting');\n\t\t\t\t// RAT-458: don't leave the SDK to spin on a stale JWT — actively\n\t\t\t\t// drive recovery from here until a channel comes back.\n\t\t\t\tthis.startRecovery();\n\t\t\t}\n\t\t}, RECONNECT_GRACE_MS);\n\t}\n\n\t/** RAT-458: begin the active-recovery loop. Resets the backoff so the first\n\t *  re-open attempt fires promptly. No-op if already running. */\n\tprivate startRecovery() {\n\t\tif (this.recovering) return;\n\t\tthis.recovering = true;\n\t\tthis.recoverAttempts = 0;\n\t\tthis.scheduleRecovery();\n\t}\n\n\tprivate scheduleRecovery() {\n\t\tif (this.recoverTimer) return; // already looping\n\t\tconst delay = Math.min(RECOVER_MAX_MS, RECOVER_BASE_MS * 2 ** this.recoverAttempts);\n\t\tthis.recoverTimer = setTimeout(() => {\n\t\t\tthis.recoverTimer = null;\n\t\t\tvoid this.recoverNow();\n\t\t}, delay);\n\t}\n\n\tprivate stopRecovery() {\n\t\tthis.recovering = false;\n\t\tif (this.recoverTimer) {\n\t\t\tclearTimeout(this.recoverTimer);\n\t\t\tthis.recoverTimer = null;\n\t\t}\n\t\tthis.recoverAttempts = 0;\n\t\t// `lastHardResetAt` deliberately survives. stopRecovery runs on every\n\t\t// online/offline flap and every tab hide, and mobile Safari fires those in\n\t\t// bursts — clearing it here reset the rate limit each time, so the loop that\n\t\t// restarts a few hundred ms later could hard-reset again immediately. That\n\t\t// is the opposite of a limit, and each reset kills the JOINs the previous\n\t\t// flap's own cycle had in flight. It's cleared on a real recovery instead\n\t\t// (setConnectionState('connected')), alongside degradedSince.\n\t}\n\n\t/** RAT-458: one recovery pass. Mint a fresh token, push it to the socket,\n\t *  and force-cycle every channel that isn't currently healthy so it rejoins\n\t *  with the new token instead of the SDK retrying on the stale one. Keeps\n\t *  rescheduling with backoff until a channel recovers (markChannelHealthy /\n\t *  stopRecovery ends the loop). */\n\tprivate async recoverNow() {\n\t\t// Recovered or torn down since we were scheduled → stop the loop.\n\t\tif (!this.recovering || !this.hasLiveChannels() || this.anyHealthy()) {\n\t\t\tthis.stopRecovery();\n\t\t\treturn;\n\t\t}\n\t\t// Can't usefully act while the tab is hidden (socket may be closing) or\n\t\t// offline. Don't do the cycle, but keep the loop alive so it resumes once\n\t\t// the tab is visible / the network is back — covers the brief-hide case\n\t\t// where onVisible never reopens because the channels were never closed.\n\t\tconst hidden = typeof document !== 'undefined' && document.hidden;\n\t\tconst offline = typeof navigator !== 'undefined' && !navigator.onLine;\n\t\tif (!hidden && !offline) {\n\t\t\tconst sb = this.getSupabase();\n\t\t\tif (sb) {\n\t\t\t\tconst token = await this.freshToken(sb);\n\t\t\t\t// The tab may have recovered / been torn down while we awaited the\n\t\t\t\t// token refresh — re-check before mutating channels.\n\t\t\t\tif (!this.recovering || this.anyHealthy() || !this.hasLiveChannels()) {\n\t\t\t\t\tthis.stopRecovery();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (token) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tsb.realtime.setAuth(token);\n\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\tif (dev) console.warn('[realtime] setAuth during recovery threw', err);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// Escalate to a full socket rebuild once channel-only cycling has\n\t\t\t\t// been failing past the hard-reset threshold — a wedged socket\n\t\t\t\t// (isConnected() true but effectively dead) swallows every reopened\n\t\t\t\t// channel's JOIN, so reopening channels alone never recovers.\n\t\t\t\t// Escalate on how long the *pill* has been up (degradedSince), not on a\n\t\t\t\t// per-loop clock: spurious online/offline flaps restart the recovery loop,\n\t\t\t\t// and the old recoveringSince reset with it, so on a flaky mobile link the\n\t\t\t\t// hard reset never accumulated enough time to fire. degradedSince survives\n\t\t\t\t// those restarts (only a real recovery clears it).\n\t\t\t\tconst degradedFor = this.degradedSince !== null ? Date.now() - this.degradedSince : 0;\n\t\t\t\tconst sinceHardReset =\n\t\t\t\t\tthis.lastHardResetAt !== null ? Date.now() - this.lastHardResetAt : Infinity;\n\t\t\t\t// `degradedFor` survives flaps by design, so after any outage longer\n\t\t\t\t// than the threshold — a tunnel, a lift — the very FIRST pass back\n\t\t\t\t// already satisfies it, ~3.5s in, and tears down the socket while the\n\t\t\t\t// JOINs this loop just sent are still inside their own 10s budget.\n\t\t\t\t// Require a failed cheap pass first: cycling the channels is what\n\t\t\t\t// normally recovers, and it deserves one full backoff window before we\n\t\t\t\t// escalate to rebuilding the socket underneath it.\n\t\t\t\tconst cheapPassTried = this.recoverAttempts >= 1;\n\t\t\t\tif (\n\t\t\t\t\tcheapPassTried &&\n\t\t\t\t\tdegradedFor >= RECOVER_HARD_RESET_MS &&\n\t\t\t\t\tsinceHardReset >= RECOVER_HARD_RESET_MS\n\t\t\t\t) {\n\t\t\t\t\t// Rate-limit repeat resets to one per threshold window, and give the\n\t\t\t\t\t// fresh socket that window to bring channels back before escalating again.\n\t\t\t\t\tthis.lastHardResetAt = Date.now();\n\t\t\t\t\tawait this.hardReconnect(sb);\n\t\t\t\t} else {\n\t\t\t\t\tfor (const entry of this.channels.values()) {\n\t\t\t\t\t\tif (!entry.healthy) {\n\t\t\t\t\t\t\tthis.closeChannel(entry);\n\t\t\t\t\t\t\tthis.openChannel(entry);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.recoverAttempts++;\n\t\t// Still degraded → keep trying with a longer backoff.\n\t\tif (this.recovering && !this.anyHealthy() && this.hasLiveChannels()) this.scheduleRecovery();\n\t}\n\n\t/** RAT-458 follow-up: full socket rebuild — the escalation when channel-only\n\t *  cycling can't recover. Reopening a channel calls `socket.connect()` only\n\t *  when the socket reports disconnected, so a half-dead socket that still\n\t *  reads as connected never gets rebuilt and every rejoin JOIN times out on\n\t *  it. Drop every channel, tear the socket down explicitly, then reopen —\n\t *  the reopen brings up a brand-new websocket. */\n\tprivate async hardReconnect(sb: SupabaseClient) {\n\t\t// Detach every channel from the (likely dead) socket first so the\n\t\t// disconnect is clean and no half-joined channel lingers on reconnect.\n\t\tfor (const entry of this.channels.values()) this.closeChannel(entry);\n\t\ttry {\n\t\t\tawait sb.realtime.disconnect();\n\t\t} catch (err) {\n\t\t\tif (dev) console.warn('[realtime] hard-reconnect disconnect threw', err);\n\t\t}\n\t\t// The tab may have recovered / been torn down across the await.\n\t\tif (!this.recovering || !this.hasLiveChannels()) return;\n\t\ttry {\n\t\t\tsb.realtime.connect();\n\t\t} catch (err) {\n\t\t\tif (dev) console.warn('[realtime] hard-reconnect connect threw', err);\n\t\t}\n\t\t// Reopen every channel — subscribe() now sees a disconnected socket and\n\t\t// brings a fresh websocket up under it.\n\t\tfor (const entry of this.channels.values()) {\n\t\t\tif (!entry.channel) this.openChannel(entry);\n\t\t}\n\t}\n\n\t/** RAT-458: mint the freshest access token we can. `getSession()` returns\n\t *  the stored session and transparently refreshes it when it's expired, so\n\t *  it covers the common stale-JWT case cheaply. If that yields nothing (or\n\t *  throws), fall back to forcing a refresh. */\n\tprivate async freshToken(sb: SupabaseClient): Promise<string | undefined> {\n\t\ttry {\n\t\t\tconst { data } = await sb.auth.getSession();\n\t\t\tif (data.session?.access_token) return data.session.access_token;\n\t\t} catch {\n\t\t\t// fall through to a forced refresh\n\t\t}\n\t\ttry {\n\t\t\tconst { data } = await sb.auth.refreshSession();\n\t\t\treturn data.session?.access_token;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t/** Channels a mounted component still listens on. One kept warm after its\n\t *  last listener left (scheduleClose) is on its way out, and its health says\n\t *  nothing about the page on screen. */\n\tprivate liveChannels(): ChannelEntry[] {\n\t\treturn [...this.channels.values()].filter((e) => e.handlers.size > 0);\n\t}\n\n\tprivate hasLiveChannels(): boolean {\n\t\tfor (const e of this.channels.values()) if (e.handlers.size > 0) return true;\n\t\treturn false;\n\t}\n\n\tprivate anyHealthy(): boolean {\n\t\tfor (const e of this.channels.values()) if (e.healthy && e.handlers.size > 0) return true;\n\t\treturn false;\n\t}\n\n\t/** RAT-458: re-auth the socket with a fresh token AND force any errored-out\n\t *  channels to rejoin. Called from the root layout on TOKEN_REFRESHED /\n\t *  SIGNED_IN: the SDK's own `setAuth` only pushes the new token to channels\n\t *  that are still *joined*, so a channel that erroed out on the old token\n\t *  would never pick it up. Cycling the unhealthy ones closes that gap. */\n\treauthenticate(token?: string) {\n\t\tif (!browser) return;\n\t\tconst sb = this.getSupabase();\n\t\tif (!sb) return;\n\t\tif (token) {\n\t\t\ttry {\n\t\t\t\tsb.realtime.setAuth(token);\n\t\t\t} catch (err) {\n\t\t\t\tif (dev) console.warn('[realtime] reauthenticate setAuth threw', err);\n\t\t\t}\n\t\t}\n\t\tfor (const entry of this.channels.values()) {\n\t\t\tif (!entry.healthy) {\n\t\t\t\tthis.closeChannel(entry);\n\t\t\t\tthis.openChannel(entry);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate deliver(entry: ChannelEntry, evt: RealtimeEvent) {\n\t\tif (entry.seenIds.has(evt.id)) return;\n\t\tentry.seenIds.add(evt.id);\n\t\tif (entry.seenIds.size > DEDUP_MAX) {\n\t\t\tconst arr = Array.from(entry.seenIds);\n\t\t\tentry.seenIds = new Set(arr.slice(arr.length - DEDUP_TRIM_TO));\n\t\t}\n\n\t\tif (evt.seq > entry.lastSeenSeq) {\n\t\t\tentry.lastSeenSeq = evt.seq;\n\t\t\tthis.writeLastSeen(entry.name, evt.seq);\n\t\t}\n\n\t\tfor (const h of entry.handlers) {\n\t\t\ttry {\n\t\t\t\th(evt);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[realtime] handler threw', { channel: entry.name, err });\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate replay(entry: ChannelEntry): Promise<void> {\n\t\t// No baseline yet → fresh tab, nothing to backfill. The SSR page load\n\t\t// already hydrated the user with whatever history they need; from here\n\t\t// the socket carries them forward.\n\t\tif (entry.lastSeenSeq === 0) return Promise.resolve();\n\t\t// Capture the cursor now: an identical in-flight replay (same sinceSeq)\n\t\t// would hit the same outbox slice, so reuse its promise instead of\n\t\t// firing a duplicate request. A later replay at an advanced cursor\n\t\t// (events delivered since) is NOT deduped — it fetches the new gap.\n\t\tconst seq = entry.lastSeenSeq;\n\t\tif (entry.replayInFlight && entry.replayInFlight.seq === seq) {\n\t\t\treturn entry.replayInFlight.promise;\n\t\t}\n\t\tconst promise = this.doReplay(entry, seq).finally(() => {\n\t\t\tif (entry.replayInFlight?.seq === seq) entry.replayInFlight = null;\n\t\t});\n\t\tentry.replayInFlight = { seq, promise };\n\t\treturn promise;\n\t}\n\n\tprivate async doReplay(entry: ChannelEntry, seq: number): Promise<void> {\n\t\ttry {\n\t\t\tconst url = `/api/realtime/replay?channel=${encodeURIComponent(entry.name)}&sinceSeq=${seq}`;\n\t\t\tconst r = await fetch(url, { credentials: 'same-origin' });\n\t\t\tif (!r.ok) return;\n\t\t\tconst data = (await r.json()) as {\n\t\t\t\tevents?: RealtimeEvent[];\n\t\t\t\ttruncated?: boolean;\n\t\t\t};\n\t\t\tif (!Array.isArray(data.events)) return;\n\t\t\tfor (const evt of data.events) this.deliver(entry, evt);\n\t\t\tif (data.truncated) this.emitSynthetic(entry, 'replay_truncated', {});\n\t\t} catch {\n\t\t\t// F3 not yet deployed, network blip, etc. Replay is best-effort; the\n\t\t\t// next socket event will catch us up eventually, and the indicator\n\t\t\t// (F5) tells the user if we're stuck reconnecting.\n\t\t}\n\t}\n\n\t/** Synthetic events the primitive emits itself (not from the outbox).\n\t *  Bypasses dedup since handlers may want to re-act to the same trigger\n\t *  across multiple reconnects (e.g. replay_truncated → re-fetch UI). */\n\tprivate emitSynthetic(entry: ChannelEntry, type: string, payload: Record<string, unknown>) {\n\t\tconst evt: RealtimeEvent = {\n\t\t\tid: `synthetic:${type}:${Date.now()}`,\n\t\t\tseq: 0,\n\t\t\ttype,\n\t\t\tpayload,\n\t\t\tchannel: entry.name,\n\t\t\tts: new Date().toISOString()\n\t\t};\n\t\tfor (const h of entry.handlers) {\n\t\t\ttry {\n\t\t\t\th(evt);\n\t\t\t} catch (err) {\n\t\t\t\tconsole.error('[realtime] synthetic handler threw', { channel: entry.name, err });\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate readLastSeen(channel: string): number {\n\t\tif (!browser) return 0;\n\t\ttry {\n\t\t\tconst v = localStorage.getItem(LAST_SEEN_KEY(channel));\n\t\t\tif (!v) return 0;\n\t\t\tconst n = parseInt(v, 10);\n\t\t\treturn Number.isFinite(n) && n > 0 ? n : 0;\n\t\t} catch {\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\tprivate writeLastSeen(channel: string, seq: number) {\n\t\tif (!browser) return;\n\t\ttry {\n\t\t\tlocalStorage.setItem(LAST_SEEN_KEY(channel), String(seq));\n\t\t} catch {\n\t\t\t// quota / private mode — lastSeenSeq stays in-memory only, which is\n\t\t\t// still correct for the current tab.\n\t\t}\n\t}\n\n\tprivate ensureInitialized() {\n\t\tif (this.initialized || !browser) return;\n\t\tthis.initialized = true;\n\n\t\tdocument.addEventListener('visibilitychange', () => {\n\t\t\tif (document.hidden) this.onHidden();\n\t\t\telse this.onVisible();\n\t\t});\n\t\twindow.addEventListener('online', () => this.onOnline());\n\t\twindow.addEventListener('offline', () => this.onOffline());\n\t}\n\n\tprivate onHidden() {\n\t\tif (this.hiddenAt === null) this.hiddenAt = Date.now();\n\t\tif (this.hiddenCloseTimer) clearTimeout(this.hiddenCloseTimer);\n\t\t// Defer the actual close: brief tab switches (alt-tab to copy text,\n\t\t// returning in <5s) shouldn't pay the reconnect cost.\n\t\tthis.hiddenCloseTimer = setTimeout(() => {\n\t\t\tthis.hiddenCloseTimer = null;\n\t\t\t// RAT-458: we're deliberately closing the socket — stop the recovery\n\t\t\t// loop so it doesn't fight the teardown. onVisible reopens and the\n\t\t\t// status callbacks re-detect / re-arm recovery if still degraded.\n\t\t\tthis.stopRecovery();\n\t\t\tfor (const entry of this.channels.values()) this.closeChannel(entry);\n\t\t\t// Leave connectionState as-is — UI is hidden, the user won't see it,\n\t\t\t// and we'll flip back to 'connected' the moment they return.\n\t\t}, HIDDEN_CLOSE_DELAY_MS);\n\t}\n\n\tprivate onVisible() {\n\t\tif (this.hiddenAt !== null) {\n\t\t\tif (this.degradedSince !== null) {\n\t\t\t\tthis.degradedHiddenMs += Date.now() - Math.max(this.hiddenAt, this.degradedSince);\n\t\t\t}\n\t\t\tthis.hiddenAt = null;\n\t\t}\n\t\tif (this.hiddenCloseTimer) {\n\t\t\tclearTimeout(this.hiddenCloseTimer);\n\t\t\tthis.hiddenCloseTimer = null;\n\t\t\t// Channels were never closed — nothing to do beyond a courtesy\n\t\t\t// replay to catch any events we might have missed during the hidden\n\t\t\t// window (heartbeat could still have dropped).\n\t\t\tfor (const entry of this.channels.values()) void this.replay(entry);\n\t\t\treturn;\n\t\t}\n\t\t// Channels were closed by the deferred timer. Reopen all — the SDK\n\t\t// status callbacks will drive connectionState via markChannel* once\n\t\t// they fire. RAT-369: no more direct flip to 'reconnecting' here.\n\t\tfor (const entry of this.channels.values()) {\n\t\t\tif (!entry.channel) this.openChannel(entry);\n\t\t}\n\t}\n\n\tprivate onOnline() {\n\t\t// The network is back, so a pending \"was that offline event real?\" check is\n\t\t// answered: it was a flap. Cancel it before it paints a pill for an outage\n\t\t// that already ended.\n\t\tif (this.offlineVerifyTimer) {\n\t\t\tclearTimeout(this.offlineVerifyTimer);\n\t\t\tthis.offlineVerifyTimer = null;\n\t\t}\n\t\t// Force-cycle every channel — the SDK's own reconnect is sometimes\n\t\t// stuck in backoff from before the network came back. The status\n\t\t// callbacks on the reopened channels will drive connectionState\n\t\t// once they settle; no need to preemptively set it here.\n\t\t// RAT-458: restart from a clean backoff — the network just changed, so\n\t\t// the previous attempt count is stale.\n\t\tthis.stopRecovery();\n\t\tfor (const entry of this.channels.values()) {\n\t\t\tthis.closeChannel(entry);\n\t\t\tthis.openChannel(entry);\n\t\t}\n\t}\n\n\tprivate onOffline() {\n\t\t// navigator's offline event is unreliable on mobile Safari: during a network\n\t\t// handoff it fires while the realtime channel is still SUBSCRIBED and\n\t\t// delivering. Don't trust it on its own — if any channel is still healthy the\n\t\t// socket is fine, so ignore the event rather than painting a red \"you're\n\t\t// offline\" pill the user can only clear by reloading (the state machine has no\n\t\t// offline→connected edge short of a fresh SUBSCRIBED callback, which an\n\t\t// already-joined channel never re-emits).\n\t\t//\n\t\t// But don't simply drop it either. `entry.healthy` only changes when the SDK\n\t\t// says so, and on a real iOS disconnect the socket often isn't closed by the\n\t\t// browser at all — the first sign is a missed heartbeat, 25s apart, plus its\n\t\t// timeout: up to ~50s of showing \"connected\" to someone with no network.\n\t\t// Re-check shortly instead. A Safari handoff flap is back inside a second or\n\t\t// two (and fires `online`, which cancels this); a genuine outage is still\n\t\t// offline, and the pill lands in seconds rather than a minute.\n\t\tif (this.anyHealthy()) {\n\t\t\tif (this.offlineVerifyTimer) return;\n\t\t\tthis.offlineVerifyTimer = setTimeout(() => {\n\t\t\t\tthis.offlineVerifyTimer = null;\n\t\t\t\tif (typeof navigator !== 'undefined' && navigator.onLine) return; // flap, ignore\n\t\t\t\tthis.stopRecovery();\n\t\t\t\tthis.setConnectionState('offline');\n\t\t\t}, OFFLINE_VERIFY_MS);\n\t\t\treturn;\n\t\t}\n\t\t// Genuinely no live channel and the OS says we're offline — recovery is futile\n\t\t// with no network; onOnline restarts it the moment the network is back.\n\t\tthis.stopRecovery();\n\t\tthis.setConnectionState('offline');\n\t}\n}\n\n/** Singleton. SSR-safe — calls into the SDK are gated by the `browser` flag,\n *  so importing this module on the server does nothing observable. */\nexport const realtime = new RealtimeClient();\n\n/** Convenience re-exports for components that only need the reactive stores. */\nexport const connectionState = realtime.connectionState;\nexport const lastConnectedAt = realtime.lastConnectedAt;\n"],"version":3}