From 2416f1863b221b394a589bac1b3e6994ec775c21 Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 13:25:49 +0200 Subject: [PATCH] feat(jmap): JMAP-over-WebSocket push (RFC 8887), preferred over SSE Phase 1 step 6 of VNCprodbuild, resolving the step-5 DECISION gate (human confirmed: WebSocket push, not polling, not quit-to-tray). lib/jmap/client.ts: getWebSocketUrl() discovers the push endpoint from the session's own urn:ietf:params:jmap:websocket capability (mirrors getEventSourceUrl()'s existing pattern) - not hardcoded to any one server, rewritten to the client's own host the same way apiUrl/downloadUrl/ eventSourceUrl already are (rewriteWebSocketUrl(), scheme-aware since ws/wss can never share an origin string with the client's http/https serverUrl). setupPushNotifications() now tries WS first when advertised, falling back to the existing SSE/polling chain when not. connectWebSocket() subscribes via WebSocketPushEnable and routes incoming StateChange frames through the exact same stateChangeCallback that SSE/polling already feed - so stores/email-store.ts's handleStateChange (mailbox/email refresh, scheduled mail, calendar, filters) and handleNewEmailNotification (the new-mail toast/ sound signal) all work unchanged regardless of which transport delivered the change. Reconnect/backoff: exponential with full jitter (1s base, 30s cap - unlike SSE's fixed 3s retry, explicitly requested since a long-lived WebSocket can be dropped by sleep/network-switch/idle-proxy repeatedly in a row). An app-level heartbeat (Core/echo every 30s, force-reconnect after 90s of silence) catches connections that report readyState OPEN long after the underlying path is actually gone, mirroring the existing SSE ping monitor. Circuit breaker (wsConsecutiveFailures/wsPermanentlyDisabled): gives up on WS after 5 CONSECUTIVE handshake failures (never reaching "open" - a connection that opened fine and dropped later doesn't count) and falls back to SSE/polling for the rest of the client instance's life. This is not theoretical - verified empirically against the actual sandbox server this was built against: curl -i -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: ..." \ -H "Sec-WebSocket-Protocol: jmap" https://stalwart.sandbox.vnc.de/jmap/ws -> 401 Unauthorized, WWW-Authenticate: Bearer/Basic Stalwart's /jmap/ws requires the same HTTP Authorization header as every other JMAP endpoint on the upgrade request itself, and the browser WebSocket constructor cannot attach custom headers to that handshake (a WHATWG spec restriction - credentials-in-URL is also explicitly rejected). Every connection attempt from this renderer-side client will therefore fail against Stalwart specifically and fall back to SSE (which keeps working exactly as before - zero regression). Implemented for real anyway, not stubbed: it's fully spec-correct and activates automatically against any server whose WS endpoint doesn't share this auth model (e.g. behind a cookie-authenticating proxy), and the alternative (opening it from Electron's main process via a header-capable client, which would need raw credentials piped over IPC from the renderer) is a materially bigger security-sensitive change than what was scoped here. Documented in detail in the code comments above the new fields. lib/jmap/client-interface.ts + lib/demo/demo-client.ts: getWebSocketUrl() added to the interface (demo client returns null, matching getEventSourceUrl's existing stub). app/(main)/[locale]/page.tsx: the existing "new mail arrived" effect (which already plays a sound, transport-agnostically, whenever stores/email-store.ts sets newEmailNotification for a genuine new top-of- inbox message) now also calls lib/electron-bridge.ts's showElectronNotification() when isElectronShell() - firing the native notification bridge built in the step-3 commit, gated on the same emailNotificationsEnabled setting the sound already uses. Fallback title/ body text ("New mail" / "(no subject)") matches public/sw.js's existing push-notification fallback strings rather than introducing new i18n keys for a rarely-hit edge case. Verified: full lib/__tests__ JMAP suite green (158/158 across 13 files, excluding one pre-existing unrelated flaky test - jmap-client-resilience's ping-failure-reconnect-ordering assertion uses real timers and fails ~75% of the time on both this branch's base commit and this change, confirmed by running the untouched baseline the same way). npm run test:electron still green (4/4) after a full rebuild. --- app/(main)/[locale]/page.tsx | 24 ++- lib/demo/demo-client.ts | 1 + lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 375 ++++++++++++++++++++++++++++++++++- 4 files changed, 393 insertions(+), 8 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 5f03ea1d..00bfd246 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -32,6 +32,7 @@ import { usePromptDialog } from "@/hooks/use-prompt-dialog"; import { useBrowserNavigation, type NavSnapshot } from "@/hooks/use-browser-navigation"; import { debug } from "@/lib/debug"; import { playNotificationSound } from "@/lib/notification-sound"; +import { isElectronShell, showElectronNotification } from "@/lib/electron-bridge"; import { cn } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; @@ -1186,13 +1187,34 @@ export default function Home() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmail?.id, isScheduledView]); - // Handle new email notifications - play sound + // Handle new email notifications - play sound, and (in the Electron shell) + // fire a native OS notification. This effect is the transport-agnostic + // "genuinely new unread mail arrived" signal - stores/email-store.ts's + // refreshCurrentMailbox() already filters out sends/moves/drafts and only + // sets newEmailNotification for a real new top-of-inbox message, and it + // fires identically whether the underlying JMAP StateChange arrived over + // the WebSocket push connection (lib/jmap/client.ts's connectWebSocket), + // SSE, or the polling fallback - no need to duplicate this per transport. useEffect(() => { if (newEmailNotification) { const { emailNotificationsEnabled, emailNotificationSound, notificationSoundChoice } = useSettingsStore.getState(); if (emailNotificationsEnabled && emailNotificationSound) { playNotificationSound(notificationSoundChoice); } + if (emailNotificationsEnabled && isElectronShell()) { + // Same fallback text public/sw.js's push handler already uses for + // its (also un-translated) system notifications - a native OS + // notification body isn't run through next-intl either way, so + // matching that existing precedent instead of introducing new + // translation keys for a rarely-hit fallback. + const sender = newEmailNotification.from?.[0]; + const senderName = sender?.name || sender?.email || 'New mail'; + const body = newEmailNotification.subject || newEmailNotification.preview || '(no subject)'; + void showElectronNotification(senderName, { + body, + tag: `bulwark-mail:${newEmailNotification.id}`, + }); + } debug.log('email', 'New email received:', newEmailNotification.subject); clearNewEmailNotification(); } diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index e7b42fc5..95fab642 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -75,6 +75,7 @@ export class DemoJMAPClient implements IJMAPClient { getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; } hasDelayedSend(): boolean { return true; } getEventSourceUrl(): string | null { return null; } + getWebSocketUrl(): string | null { return null; } supportsEmailSubmission(): boolean { return true; } supportsQuota(): boolean { return true; } supportsVacationResponse(): boolean { return true; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 2693f3d8..20750d24 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -35,6 +35,7 @@ export interface IJMAPClient { getMaxDelayedSend(accountId?: string): number; hasDelayedSend(accountId?: string): boolean; getEventSourceUrl(): string | null; + getWebSocketUrl(): string | null; supportsEmailSubmission(): boolean; supportsQuota(): boolean; supportsVacationResponse(): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 63e368fa..79a8b3f3 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -953,6 +953,36 @@ export class JMAPClient implements IJMAPClient { if (session.eventSourceUrl) { session.eventSourceUrl = this.rewriteSessionUrl(session.eventSourceUrl); } + const wsCapability = session.capabilities?.["urn:ietf:params:jmap:websocket"] as + | { url?: string } + | undefined; + if (wsCapability?.url) { + wsCapability.url = this.rewriteWebSocketUrl(wsCapability.url); + } + } + + /** + * Same reasoning as rewriteSessionUrl (a reverse proxy may advertise its + * own internal hostname), but scheme-aware: unlike apiUrl/eventSourceUrl, + * this URL is never touched by fetch() - it goes straight into `new + * WebSocket(...)`, and a ws/wss URL can never share an origin string with + * an http/https serverUrl even when the host is identical, so reusing + * rewriteSessionUrl's plain origin-equality check would rewrite EVERY + * websocket URL onto an http(s) scheme and break the constructor outright. + */ + private rewriteWebSocketUrl(url: string): string { + try { + const parsed = new URL(url); + const server = new URL(this.serverUrl); + const expectedScheme = server.protocol === "https:" ? "wss:" : "ws:"; + if (parsed.host === server.host && parsed.protocol === expectedScheme) { + return url; + } + const pathAndRest = url.slice(url.indexOf("/", url.indexOf("//") + 2)); + return `${expectedScheme}//${server.host}${pathAndRest}`; + } catch { + return url; + } } private async request(methodCalls: JMAPMethodCall[], using?: string[]): Promise { @@ -3761,6 +3791,22 @@ export class JMAPClient implements IJMAPClient { return this.session.eventSourceUrl || coreCapability?.eventSourceUrl || null; } + /** + * RFC 8887 (JMAP over WebSocket) push endpoint, advertised under the + * `urn:ietf:params:jmap:websocket` capability (not a root session field + * like eventSourceUrl - it's nested the same way every other JMAP + * extension capability is). Rewritten to the client's own server host in + * rewriteSessionUrls() at connect time, same reasoning as apiUrl/ + * downloadUrl/eventSourceUrl. Returns null for servers that don't + * advertise it - callers fall back to SSE/polling. + */ + getWebSocketUrl(): string | null { + const wsCapability = this.capabilities["urn:ietf:params:jmap:websocket"] as + | { url?: string; supportsPush?: boolean } + | undefined; + return wsCapability?.url || null; + } + getAccountId(): string { return this.accountId; } @@ -5982,6 +6028,43 @@ export class JMAPClient implements IJMAPClient { private visibilityHandler: (() => void) | null = null; private onlineHandler: (() => void) | null = null; + // JMAP-over-WebSocket (RFC 8887) push - preferred over SSE when the server + // advertises it (getWebSocketUrl()), since it's the transport the desktop + // shell's main process eventually wants for background/no-window + // notifications (see electron/preload.ts's showNotification bridge). + // Falls back to the existing SSE/polling chain below when unsupported OR + // when the handshake itself keeps failing (see wsPermanentlyDisabled). + // + // KNOWN LIMITATION, confirmed empirically against the sandbox server this + // was built against (stalwart.sandbox.vnc.de): its /jmap/ws endpoint + // requires the same HTTP Basic/Bearer Authorization header as every other + // JMAP endpoint on the WebSocket UPGRADE request itself (curling it with + // no Authorization header returns a plain 401 before any WS frame is + // possible). The browser WebSocket constructor has no way to attach + // custom headers to that handshake (a WHATWG spec restriction, not an + // Electron/browser quirk - credentials in the URL are actively rejected + // too), so from this renderer-side client there is no way to satisfy that + // auth requirement. Against a server with this exact auth model, every + // connection attempt below will fail at the handshake and the circuit + // breaker (wsPermanentlyDisabled) will fall back to SSE after a few quick + // retries - which is not a bug in this code, it is what actually happens + // on the wire. It's still implemented for real (not stubbed) because (a) + // it's fully spec-correct and will light up automatically against any + // server whose WS endpoint doesn't have this requirement - e.g. one + // sitting behind a proxy that authenticates via cookies instead - with no + // further changes, and (b) the alternative (opening it from Electron's + // main process via a header-capable client like the `ws` package) would + // mean piping raw credentials from the renderer to the main process over + // IPC, which is a materially bigger security-sensitive change than what + // was scoped here. + private ws: WebSocket | null = null; + private wsReconnectTimeout: NodeJS.Timeout | null = null; + private wsReconnectAttempts: number = 0; + private wsConsecutiveFailures: number = 0; + private wsPermanentlyDisabled: boolean = false; + private wsHeartbeatTimer: NodeJS.Timeout | null = null; + private lastWSActivity: number = 0; + private static readonly STATE_TYPE_MAP: Record = { 'Mailbox/get': 'Mailbox', 'Email/get': 'Email', @@ -5998,20 +6081,262 @@ export class JMAPClient implements IJMAPClient { private static readonly SSE_RECONNECT_DELAY = 3_000; private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval + // Exponential backoff with full jitter (0..cap), doubling from a 1s base + // and capping at 30s - unlike SSE's fixed 3s retry, a long-lived WebSocket + // genuinely needs backoff: it can be closed by a server-side idle timeout, + // a proxy, or a laptop sleep/wake cycle repeatedly in a row, and hammering + // a reconnect every 3s in that situation is exactly the kind of thing that + // gets a client rate-limited (see isRateLimited()/setRateLimited() above). + private static readonly WS_RECONNECT_BASE_DELAY = 1_000; + private static readonly WS_RECONNECT_MAX_DELAY = 30_000; + // App-level heartbeat: a WebSocket can sit in "open" readyState for a long + // time after the underlying network path is actually gone (sleep, network + // switch, a NAT/proxy that silently drops idle connections) - TCP alone + // won't always surface that promptly. Send a lightweight JMAP request + // every 30s and force-reconnect if nothing (heartbeat response OR a real + // push) has arrived within 3x that window, mirroring the SSE ping monitor + // above. + private static readonly WS_HEARTBEAT_INTERVAL = 30_000; + private static readonly WS_ACTIVITY_TIMEOUT = 90_000; + // Give up on WS for this client instance after this many CONSECUTIVE + // attempts that never reach "open" (a connection that opened fine and + // later dropped does not count - see connectWebSocket's openedSuccessfully + // tracking). Bounds the cost of the auth limitation described above to a + // handful of quick handshake attempts (worst case a bit over 30s of + // jittered backoff) instead of retrying a request that can never succeed, + // forever, every ~30s, for the lifetime of the session. + private static readonly WS_MAX_CONSECUTIVE_FAILURES = 5; + + /** getWebSocketUrl(), gated by the circuit breaker above. */ + private effectiveWebSocketUrl(): string | null { + return this.wsPermanentlyDisabled ? null : this.getWebSocketUrl(); + } + setupPushNotifications(): boolean { - const eventSourceUrl = this.getEventSourceUrl(); - if (eventSourceUrl) { - this.connectSSE(eventSourceUrl); - // SSE covers the primary account only; keep shared accounts fresh too. + const wsUrl = this.effectiveWebSocketUrl(); + if (wsUrl) { + this.wsReconnectAttempts = 0; + this.connectWebSocket(wsUrl); + // Not confirmed either way whether this server's WebSocket push fans + // out to shared/secondary accounts or, like Stalwart's SSE, covers the + // primary account only - keep the same secondary poll running under + // WS that SSE already needed, rather than assume broader coverage and + // risk shared-account counters going stale. this.startSecondaryAccountPoll(); } else { - // The fallback poll already covers every session account. - this.startPollingFallback(); + const eventSourceUrl = this.getEventSourceUrl(); + if (eventSourceUrl) { + this.connectSSE(eventSourceUrl); + // SSE covers the primary account only; keep shared accounts fresh too. + this.startSecondaryAccountPoll(); + } else { + // The fallback poll already covers every session account. + this.startPollingFallback(); + } } this.setupBrowserEventListeners(); return true; } + /** + * Opens the RFC 8887 JMAP-over-WebSocket connection and subscribes to + * push for every data type (`WebSocketPushEnable` with dataTypes: null). + * Reconnect on close/error is handled by scheduleWSReconnect() below with + * exponential backoff - this method only ever represents a single + * connection attempt. + */ + private connectWebSocket(wsUrl: string): void { + if (this.isRateLimited()) { + this.scheduleWSReconnect(); + return; + } + + let socket: WebSocket; + try { + socket = new WebSocket(wsUrl, "jmap"); + } catch { + // New URL()-level failures (malformed URL) - retry later in case a + // session refresh fixes it; getWebSocketUrl() re-reads capabilities + // fresh on every attempt. + this.scheduleWSReconnect(); + return; + } + + this.ws = socket; + const isCurrent = () => this.ws === socket; + // Tracks whether THIS specific attempt ever reached "open" - a socket + // that opened fine and dropped later (real network blip on an + // established connection) must not count toward the circuit breaker the + // same way a handshake that never completes does (see + // wsPermanentlyDisabled's declaration above for why the latter needs + // one at all). + let openedSuccessfully = false; + + socket.addEventListener("open", () => { + if (!isCurrent()) return; + openedSuccessfully = true; + // A real connection succeeded - both counters reset: the backoff + // ladder no longer applies to whatever eventually causes the NEXT + // disconnect, and the "give up on WS entirely" counter only tracks + // CONSECUTIVE handshake failures. + this.wsReconnectAttempts = 0; + this.wsConsecutiveFailures = 0; + this.lastWSActivity = Date.now(); + this.startWSHeartbeat(socket); + try { + socket.send(JSON.stringify({ "@type": "WebSocketPushEnable", dataTypes: null })); + } catch { + // send() can throw if the socket already closed between "open" + // firing and this line running - the "close" handler below will + // schedule a reconnect regardless. + } + }); + + socket.addEventListener("message", (event) => { + if (!isCurrent()) return; + this.lastWSActivity = Date.now(); + this.processWebSocketMessage(typeof event.data === "string" ? event.data : ""); + }); + + socket.addEventListener("close", () => { + if (!isCurrent()) return; + this.stopWSHeartbeat(); + this.ws = null; + if (this.intentionallyDisconnected) return; + + if (!openedSuccessfully) { + this.wsConsecutiveFailures += 1; + if (this.wsConsecutiveFailures >= JMAPClient.WS_MAX_CONSECUTIVE_FAILURES) { + // The handshake itself is what's failing, repeatedly - most + // commonly (confirmed against this client's own reference + // server) because the WS endpoint requires an Authorization + // header the browser WebSocket API cannot attach. Retrying that + // forever would just hammer the server every ~30s with a request + // that can never succeed from here. Give up on WS for the rest of + // this client instance's life and stay on SSE/polling, which + // don't have this limitation. + this.wsPermanentlyDisabled = true; + console.warn( + '[JMAP] WebSocket push failed to establish after repeated attempts; falling back to SSE/polling for this session.', + ); + this.fallbackFromWebSocket(); + return; + } + } + + this.scheduleWSReconnect(); + }); + + // WebSocket always fires "close" right after "error" - the reconnect + // logic lives entirely in the "close" handler above so there is exactly + // one path that schedules a retry, not two racing each other. + } + + /** Whatever push transport SSE would have used, now that WS has given up. */ + private fallbackFromWebSocket(): void { + const eventSourceUrl = this.getEventSourceUrl(); + if (eventSourceUrl) { + this.connectSSE(eventSourceUrl); + this.startSecondaryAccountPoll(); + } else { + this.startPollingFallback(); + } + } + + /** + * Parses one WebSocket text frame. Per RFC 8887 the server can send + * Response, StateChange, or PushState frames; only StateChange is + * consumed today (method calls aren't yet routed over this socket - + * request()/authenticatedFetch() still uses plain HTTP), so anything else + * is silently ignored rather than treated as an error. + */ + private processWebSocketMessage(raw: string): void { + if (!raw) return; + let message: { "@type"?: string; changed?: StateChange["changed"] } | null = null; + try { + message = JSON.parse(raw); + } catch { + return; // malformed frame - ignore, matches processSSEEvent's handling + } + if (message?.["@type"] === "StateChange" && message.changed) { + this.stateChangeCallback?.({ "@type": "StateChange", changed: message.changed }); + } + } + + private scheduleWSReconnect(): void { + if (this.intentionallyDisconnected) return; + if (this.wsReconnectTimeout) return; // already scheduled - don't stack retries + + const wsUrl = this.effectiveWebSocketUrl(); + if (!wsUrl) { + // Either the server capability disappeared (e.g. a session refresh + // dropped WebSocket support) or the circuit breaker already tripped - + // fall back to whatever push transport is still available instead of + // retrying a URL that's gone or a handshake that won't succeed. + this.fallbackFromWebSocket(); + return; + } + + const attempt = this.wsReconnectAttempts; + this.wsReconnectAttempts += 1; + const exponential = JMAPClient.WS_RECONNECT_BASE_DELAY * Math.pow(2, attempt); + const cap = Math.min(exponential, JMAPClient.WS_RECONNECT_MAX_DELAY); + // Full jitter (uniform 0..cap) rather than a fixed exponential delay - + // spreads reconnect attempts out after a shared network blip (proxy + // restart, wifi handoff affecting every open tab/window at once) + // instead of having them all retry in lockstep. + const delay = Math.random() * cap; + + this.wsReconnectTimeout = setTimeout(() => { + this.wsReconnectTimeout = null; + if (this.isRateLimited()) { + this.scheduleWSReconnect(); + return; + } + this.connectWebSocket(wsUrl); + }, delay); + } + + private startWSHeartbeat(socket: WebSocket): void { + this.stopWSHeartbeat(); + this.wsHeartbeatTimer = setInterval(() => { + if (this.ws !== socket) return; + if (Date.now() - this.lastWSActivity > JMAPClient.WS_ACTIVITY_TIMEOUT) { + // Silently dead connection (sleep/network switch/idle proxy) - the + // socket can still report readyState OPEN long after the underlying + // path is gone. Force-close; the "close" handler schedules the + // reconnect via the normal backoff path. + this.stopWSHeartbeat(); + try { + socket.close(); + } catch { + // Already closing/closed - the "close" handler (if it hasn't + // already run) will still fire and take care of reconnecting. + } + return; + } + try { + socket.send(JSON.stringify({ + "@type": "Request", + requestId: `ws-heartbeat-${Date.now()}`, + using: ["urn:ietf:params:jmap:core"], + methodCalls: [["Core/echo", {}, "0"]], + })); + } catch { + // send() failing means the socket is already dead - the activity + // timeout above will catch it on the next tick if "close" doesn't + // fire first. + } + }, JMAPClient.WS_HEARTBEAT_INTERVAL); + } + + private stopWSHeartbeat(): void { + if (this.wsHeartbeatTimer) { + clearInterval(this.wsHeartbeatTimer); + this.wsHeartbeatTimer = null; + } + } + /** * Slow poll of the session's shared/secondary accounts, run in parallel with * SSE (which never reports them). Skipped when there are no shared accounts, @@ -6310,6 +6635,27 @@ export class JMAPClient implements IJMAPClient { this.eventSource = null; } this.stopSSEPingMonitor(); + if (this.wsReconnectTimeout) { + clearTimeout(this.wsReconnectTimeout); + this.wsReconnectTimeout = null; + } + this.stopWSHeartbeat(); + if (this.ws) { + // Null out this.ws BEFORE close() so the "close" event handler's + // isCurrent() check (this.ws === socket) sees a mismatch once the + // event fires and skips scheduling a reconnect - this is an + // intentional teardown, not a dropped connection. + const socket = this.ws; + this.ws = null; + try { + socket.close(); + } catch { + // Already closing/closed. + } + } + this.wsReconnectAttempts = 0; + this.wsConsecutiveFailures = 0; + this.wsPermanentlyDisabled = false; this.cleanupBrowserEventListeners(); this.stateChangeCallback = null; this.pollingStates = {}; @@ -6350,7 +6696,22 @@ export class JMAPClient implements IJMAPClient { if (typeof window !== 'undefined') { this.onlineHandler = () => { - // Network reconnected - reconnect SSE or force a poll + // Network reconnected - reconnect WS/SSE or force a poll. Don't + // make the user wait through whatever backoff delay was already in + // flight from repeated failures while offline - the network is + // confirmed back, so retry immediately. + const wsUrl = this.effectiveWebSocketUrl(); + if (wsUrl) { + if (!this.ws) { + if (this.wsReconnectTimeout) { + clearTimeout(this.wsReconnectTimeout); + this.wsReconnectTimeout = null; + } + this.wsReconnectAttempts = 0; + this.connectWebSocket(wsUrl); + } + return; + } const eventSourceUrl = this.getEventSourceUrl(); if (eventSourceUrl && !this.sseAbortController) { this.connectSSE(eventSourceUrl);