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.
This commit is contained in:
Bernd Rodler
2026-08-04 13:25:49 +02:00
parent 568b7137ea
commit 2416f1863b
4 changed files with 393 additions and 8 deletions
+368 -7
View File
@@ -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<JMAPResponse> {
@@ -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<string, string> = {
'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);