{"release_id":"01a1c502-4ad1-0000-ec62-adc54d3c69aa","chunk_id":"01a1c502-4c4d-7311-8433-ddd645fce835","file":"f2UJKXgA.mjs","mappings":"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","names":["SPECTATOR_TOPIC_SCOPES","PUBLIC_TOPIC_SCOPES","PUBLIC","SPECTATOR","topicScope","topic","sep","isPrivateTopic","isOpenReadTopic","MAX_PLAUSIBLE_SEQ","MAX_SEQ_JUMP","isValidSeq","seq","parseRealtimeEvent","raw","channel","lastSeenSeq","e","LAST_SEEN_KEY","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","isPrivate","ch","evt","openRead","ok","status","err","isAccessDenied","token","data","next","prev","healthyCount","path","live","kinds","entries","trackRealtimeConnectionState","degradedMs","hiddenMs","now","delay","hidden","offline","degradedFor","sinceHardReset","arr","h","promise","url","r","type","payload","v","n","browser","realtime","connectionState"],"sources":["../../../../../../src/lib/realtime/topics.ts","../../../../../../src/lib/realtime/validate.ts","../../../../../../src/lib/realtime/client.ts"],"sourcesContent":["/** Which realtime topics are private (Supabase Realtime Authorization).\n *\n *  Shared by the server dispatcher ($lib/server/realtime/dispatch.ts) and the\n *  browser client (./client.ts): both sides MUST agree, because a private\n *  broadcast only reaches subscribers that joined with `config.private: true`,\n *  and a public one only reaches those that didn't.\n *\n *  Private topics carry personal data (DM bodies, notifications, group\n *  messages, game seats). Joining one needs a signed-in JWT that passes the\n *  `realtime.messages` SELECT policy (supabase/migrations/\n *  20261109000020_realtime_private_channels.sql). Nobody but the server can\n *  send on them: there is no INSERT policy.\n *\n *  Spectator topics (live, contest, conquest) are private too, but readable by\n *  anyone: the policy grants SELECT to anon and authenticated, so a signed-out\n *  viewer joins with the anon key. Being private is what stops forgery: with no\n *  INSERT policy nobody but the server can send on them (a public topic takes a\n *  broadcast from any anon-key client: fake tips, fake staff comments).\n *\n *  Public topics are the ones clients legitimately send on: `duel:<code>`\n *  (reactions, comments, presence) and `duel:matchmaking` (presence, match\n *  claims). They stay public until those sends move server-side. The raw\n *  presence channels (live-presence:, floor-presence:, group-room:,\n *  rate-session:) don't go through this module and also join public; making\n *  them private needs presence policies (extension = 'presence', select +\n *  insert), which is a separate change.\n *\n *  Unknown scopes are private (fail closed): a new topic leaks nothing until\n *  someone decides it is public and adds it here + a policy branch. */\n\nexport const PRIVATE_TOPIC_SCOPES = ['user', 'thread', 'room', 'tod', 'poker'] as const;\nexport const SPECTATOR_TOPIC_SCOPES = ['live', 'contest', 'conquest'] as const;\nexport const PUBLIC_TOPIC_SCOPES = ['duel'] as const;\n\nconst PUBLIC = new Set<string>(PUBLIC_TOPIC_SCOPES);\nconst SPECTATOR = new Set<string>(SPECTATOR_TOPIC_SCOPES);\n\n/** `user:abc` → `user`. A topic without a `:` has no scope. */\nexport function topicScope(topic: string): string {\n\tconst sep = topic.indexOf(':');\n\treturn sep < 0 ? '' : topic.slice(0, sep);\n}\n\n/** Sent and joined with `private: true`. */\nexport function isPrivateTopic(topic: string): boolean {\n\treturn !PUBLIC.has(topicScope(topic));\n}\n\n/** Private, but the policy lets anyone read it: join without a session. */\nexport function isOpenReadTopic(topic: string): boolean {\n\treturn SPECTATOR.has(topicScope(topic));\n}\n","/** Shape + seq checks for incoming realtime events.\n *\n *  Public topics (see ./topics.ts) accept broadcasts from any client holding the\n *  anon key, so a frame on the socket can be forged. The client only trusts a\n *  frame that has the full wire shape, names the channel it arrived on, and\n *  carries a plausible `seq` — a forged huge seq would otherwise be persisted as\n *  the replay cursor and hide every real event below it. */\n\nimport type { RealtimeEvent } from './types';\n\n/** `seq` is one bigserial across the whole outbox. Even at millions of events a\n *  day it stays far below this for centuries; anything above is forged. */\nexport const MAX_PLAUSIBLE_SEQ = 1e12;\n/** Largest forward jump accepted from the socket relative to the channel's\n *  cursor. Generous: the outbox seq is global, so a quiet channel legitimately\n *  skips everything other channels used in between. */\nexport const MAX_SEQ_JUMP = 1e10;\n\nexport function isValidSeq(seq: unknown): seq is number {\n\treturn Number.isSafeInteger(seq) && (seq as number) > 0 && (seq as number) <= MAX_PLAUSIBLE_SEQ;\n}\n\n/** Returns the event when it has the full wire shape and belongs to `channel`,\n *  otherwise null. `lastSeenSeq` (when > 0) bounds how far ahead a live frame\n *  may jump; pass 0 for server-authoritative replay rows. */\nexport function parseRealtimeEvent(\n\traw: unknown,\n\tchannel: string,\n\tlastSeenSeq = 0\n): RealtimeEvent | null {\n\tif (!raw || typeof raw !== 'object') return null;\n\tconst e = raw as Record<string, unknown>;\n\tif (typeof e.id !== 'string' || !e.id) return null;\n\tif (typeof e.type !== 'string' || !e.type) return null;\n\tif (typeof e.ts !== 'string') return null;\n\tif (e.channel !== channel) return null;\n\tif (!e.payload || typeof e.payload !== 'object' || Array.isArray(e.payload)) return null;\n\tif (!isValidSeq(e.seq)) return null;\n\tif (lastSeenSeq > 0 && e.seq > lastSeenSeq + MAX_SEQ_JUMP) return null;\n\treturn e as unknown as RealtimeEvent;\n}\n","/** 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';\nimport { isOpenReadTopic, isPrivateTopic } from './topics';\nimport { isValidSeq, parseRealtimeEvent } from './validate';\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\t/** Private topic whose join the realtime.messages policy refused (not a\n\t *  member, not your user channel). Retrying can't help, so the reopen loops\n\t *  (recovery, online, visible) skip it and it doesn't count towards the pill.\n\t *  Cleared by a new subscribe() or a re-auth (membership / user may change). */\n\tdenied: boolean;\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\tdenied: false\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\t// A new listener is a fresh intent (e.g. just joined the room): give a\n\t\t// previously refused private topic one more join attempt.\n\t\tentry.denied = false;\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\tif (entry.denied) return;\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\t// Personal topics (user / thread / room / tod / poker) are private: the\n\t\t// server broadcasts them with `private: true` and Realtime only delivers\n\t\t// them to a join that carries a JWT passing the realtime.messages policy.\n\t\t// Spectator topics (live / contest / conquest) are private with an\n\t\t// open-read policy, so only the server can send on them.\n\t\tconst isPrivate = isPrivateTopic(entry.name);\n\t\tconst ch = sb.channel(entry.name, { config: { private: isPrivate } });\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\t// Public topics accept broadcasts from any anon-key client, so a frame\n\t\t\t// can be forged: require the full wire shape, this channel's own name\n\t\t\t// and a plausible seq (a huge one would poison the replay cursor).\n\t\t\tconst evt = parseRealtimeEvent(raw.payload, entry.name, entry.lastSeenSeq);\n\t\t\tif (!evt) {\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\tentry.channel = ch;\n\t\tif (!isPrivate) {\n\t\t\tthis.joinChannel(entry, ch);\n\t\t\treturn;\n\t\t}\n\t\t// Private join needs the user's access token on the socket first, else it\n\t\t// goes out with the anon key and is refused. Signed out → don't join,\n\t\t// except on a spectator topic: the anon key passes its open-read policy.\n\t\tconst openRead = isOpenReadTopic(entry.name);\n\t\tvoid this.authorizePrivateJoin(sb).then((ok) => {\n\t\t\tif (entry.channel !== ch) return; // closed or reopened while we waited\n\t\t\tif (!ok && !openRead) {\n\t\t\t\tsb.removeChannel(ch);\n\t\t\t\tentry.channel = null;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.joinChannel(entry, ch);\n\t\t});\n\t}\n\n\tprivate joinChannel(entry: ChannelEntry, ch: RealtimeChannel) {\n\t\tch.subscribe((status, err) => {\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' && isAccessDenied(err)) {\n\t\t\t\t// The policy said no (not a member). Not an outage: no pill, no retry.\n\t\t\t\tif (entry.channel !== ch) return; // a stale handle, a newer join owns the entry\n\t\t\t\tentry.denied = true;\n\t\t\t\tthis.closeChannel(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}\n\n\t/** Put the signed-in user's JWT on the socket before a private join.\n\t *  `getSession()` refreshes an expired session on the way. False when signed\n\t *  out: private topics are for signed-in users only. Later refreshes reach\n\t *  joined channels through supabase-js's own TOKEN_REFRESHED handler and the\n\t *  root layout's reauthenticate(). */\n\tprivate async authorizePrivateJoin(sb: SupabaseClient): Promise<boolean> {\n\t\tlet token: string | undefined;\n\t\ttry {\n\t\t\tconst { data } = await sb.auth.getSession();\n\t\t\ttoken = data.session?.access_token;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t\tif (!token) return false;\n\t\ttry {\n\t\t\tawait sb.realtime.setAuth(token);\n\t\t} catch (err) {\n\t\t\tif (dev) console.warn('[realtime] setAuth before private join threw', err);\n\t\t}\n\t\treturn true;\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// Refused by the policy and closed on purpose: nothing to recover.\n\t\tif (entry.denied) return;\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 && !e.denied);\n\t}\n\n\tprivate hasLiveChannels(): boolean {\n\t\tfor (const e of this.channels.values()) if (e.handlers.size > 0 && !e.denied) 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\t// A new token may be a new user (sign-in) whose topics the policy allows.\n\t\t\tentry.denied = false;\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?: unknown[];\n\t\t\t\ttruncated?: boolean;\n\t\t\t\tcursorReset?: unknown;\n\t\t\t};\n\t\t\t// The server saw a cursor beyond the outbox head (a forged seq persisted\n\t\t\t// before this guard existed): rewind to the head so replay works again.\n\t\t\tif (isValidSeq(data.cursorReset) && data.cursorReset < entry.lastSeenSeq) {\n\t\t\t\tentry.lastSeenSeq = data.cursorReset;\n\t\t\t\tthis.writeLastSeen(entry.name, data.cursorReset);\n\t\t\t}\n\t\t\tif (!Array.isArray(data.events)) return;\n\t\t\tfor (const raw of data.events) {\n\t\t\t\tconst evt = parseRealtimeEvent(raw, entry.name);\n\t\t\t\tif (evt) this.deliver(entry, evt);\n\t\t\t}\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\t// a cursor poisoned by a forged huge seq (before this guard) resets to 0\n\t\t\treturn isValidSeq(n) ? 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/** Realtime refuses a private join the realtime.messages policy doesn't allow\n *  with \"Unauthorized: You do not have permissions to read from this Channel\n *  topic: …\". Token problems (expired / invalid JWT) read differently and stay\n *  on the recovery path. */\nfunction isAccessDenied(err: Error | undefined): boolean {\n\treturn !!err && /permissions to read/i.test(err.message);\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}