2026-07-17 23:20:34 +02:00
|
|
|
// WebSocket layer (SPEC §9.2). Validates frames with zod and invalidates
|
|
|
|
|
// TanStack Query caches keyed on ids in the payload.
|
|
|
|
|
|
|
|
|
|
import type { QueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { z } from "zod";
|
|
|
|
|
|
2026-10-01 20:16:27 +02:00
|
|
|
import { signedOut } from "./auth";
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
const frameSchema = z.object({
|
|
|
|
|
event: z.string(),
|
|
|
|
|
payload: z.record(z.unknown()),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const ALLOWED_EVENTS = new Set([
|
|
|
|
|
"message.new",
|
|
|
|
|
"message.changed",
|
|
|
|
|
"message.deleted",
|
|
|
|
|
"message.sent",
|
|
|
|
|
"flags.changed",
|
|
|
|
|
"thread.updated",
|
|
|
|
|
"sync.progress",
|
|
|
|
|
"account.state_changed",
|
|
|
|
|
"draft.saved",
|
|
|
|
|
"outbox.state_changed",
|
2026-10-01 20:16:27 +02:00
|
|
|
"user.signed_out",
|
2026-07-17 23:20:34 +02:00
|
|
|
]);
|
|
|
|
|
|
2026-07-27 08:30:24 +02:00
|
|
|
// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3).
|
|
|
|
|
// Plugins receive plugin.{name}.* events plus any core event they subscribe to.
|
|
|
|
|
export type WsListener = (event: string, payload: Record<string, unknown>) => void;
|
|
|
|
|
|
|
|
|
|
const wsListeners = new Set<WsListener>();
|
|
|
|
|
|
|
|
|
|
export function subscribeWs(listener: WsListener): () => void {
|
|
|
|
|
wsListeners.add(listener);
|
|
|
|
|
return () => {
|
|
|
|
|
wsListeners.delete(listener);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
export function connectWs(queryClient: QueryClient): () => void {
|
|
|
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
2026-08-05 18:04:49 +02:00
|
|
|
const url = `${proto}://${window.location.host}/api/ws`;
|
2026-07-17 23:20:34 +02:00
|
|
|
|
2026-08-05 18:04:49 +02:00
|
|
|
let socket: WebSocket | null = null;
|
|
|
|
|
let ping: number | undefined;
|
|
|
|
|
let reconnectTimer: number | undefined;
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
let stopped = false;
|
|
|
|
|
|
|
|
|
|
// Refetch the live-updated lists — used on every (re)connect so a dropped socket
|
|
|
|
|
// (server restart, sleep, network blip) self-heals and catches up on missed events.
|
|
|
|
|
const resync = () => {
|
|
|
|
|
for (const key of [
|
|
|
|
|
["threads"],
|
|
|
|
|
["accounts"],
|
|
|
|
|
["folder-counts"],
|
|
|
|
|
["quota"],
|
|
|
|
|
["drafts-remote"],
|
|
|
|
|
]) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: key });
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
2026-08-05 18:04:49 +02:00
|
|
|
};
|
2026-07-17 23:20:34 +02:00
|
|
|
|
2026-08-05 18:04:49 +02:00
|
|
|
const handleMessage = (event: MessageEvent) => {
|
2026-07-17 23:20:34 +02:00
|
|
|
let parsed: unknown;
|
|
|
|
|
try {
|
|
|
|
|
parsed = JSON.parse(event.data);
|
|
|
|
|
} catch {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const result = frameSchema.safeParse(parsed);
|
2026-07-27 08:30:24 +02:00
|
|
|
if (!result.success) return;
|
2026-07-17 23:20:34 +02:00
|
|
|
const { event: name, payload } = result.data;
|
2026-07-27 08:30:24 +02:00
|
|
|
const isCore = ALLOWED_EVENTS.has(name);
|
|
|
|
|
// Fan out core + plugin.* events to plugin subscribers (a failing listener
|
|
|
|
|
// must not break the others or the cache invalidation below).
|
|
|
|
|
if (isCore || name.startsWith("plugin.")) {
|
|
|
|
|
for (const listener of wsListeners) {
|
|
|
|
|
try {
|
|
|
|
|
listener(name, payload);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("ws listener failed", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!isCore) return;
|
2026-10-01 20:16:27 +02:00
|
|
|
if (name === "user.signed_out") {
|
|
|
|
|
// An administrator blocked or deleted this account: leave now, rather
|
|
|
|
|
// than keep showing mail that was already loaded.
|
|
|
|
|
signedOut(queryClient);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
// Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
|
|
|
|
|
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
2026-07-18 15:27:09 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["quota"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["drafts-remote"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-23 23:00:08 +02:00
|
|
|
// Backfill progress counters live on the account row (drives the banner).
|
|
|
|
|
if (name === "sync.progress") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
if (typeof payload.thread_id === "string") {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread", payload.thread_id] });
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-22 07:40:29 +02:00
|
|
|
if (name === "flags.changed") {
|
|
|
|
|
// Read/unread changes affect the Inbox unread badge.
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
|
|
|
|
}
|
2026-07-17 23:20:34 +02:00
|
|
|
if (name === "account.state_changed") {
|
2026-08-05 18:04:49 +02:00
|
|
|
// Adding/deleting an account changes threads and (Gmail) labels wholesale —
|
|
|
|
|
// refetch the list, chips, and sidebar so nothing stale lingers.
|
2026-07-17 23:20:34 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["accounts"] });
|
2026-08-05 18:04:49 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["threads"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["thread-labels"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["message-labels"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["labels-all"] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
|
|
|
|
window.dispatchEvent(new Event("webmail:labels-changed"));
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
|
|
|
|
if (name === "message.sent") {
|
|
|
|
|
// The sent message's draft is deleted server-side; refresh the Drafts list.
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["drafts"] });
|
2026-07-22 07:40:29 +02:00
|
|
|
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
|
2026-07-17 23:20:34 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-05 18:04:49 +02:00
|
|
|
const connect = () => {
|
|
|
|
|
socket = new WebSocket(url);
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
attempts = 0;
|
|
|
|
|
ping = window.setInterval(() => {
|
|
|
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
|
|
|
socket.send(JSON.stringify({ type: "ping" }));
|
|
|
|
|
}
|
|
|
|
|
}, 30000);
|
|
|
|
|
resync(); // catch up on anything published while we were disconnected
|
|
|
|
|
};
|
|
|
|
|
socket.onmessage = handleMessage;
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
if (ping !== undefined) window.clearInterval(ping);
|
|
|
|
|
if (stopped) return;
|
|
|
|
|
// Exponential backoff, capped at 30s, so a downed server is retried politely.
|
|
|
|
|
const delay = Math.min(30000, 1000 * 2 ** attempts);
|
|
|
|
|
attempts += 1;
|
|
|
|
|
reconnectTimer = window.setTimeout(connect, delay);
|
|
|
|
|
};
|
|
|
|
|
socket.onerror = () => socket?.close(); // route errors through onclose -> reconnect
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
connect();
|
|
|
|
|
|
2026-07-17 23:20:34 +02:00
|
|
|
return () => {
|
2026-08-05 18:04:49 +02:00
|
|
|
stopped = true;
|
|
|
|
|
if (ping !== undefined) window.clearInterval(ping);
|
|
|
|
|
if (reconnectTimer !== undefined) window.clearTimeout(reconnectTimer);
|
|
|
|
|
socket?.close();
|
2026-07-17 23:20:34 +02:00
|
|
|
};
|
|
|
|
|
}
|