F4 lists real-time updates and the interface had none: an open inbox only changed when the user reloaded it. Events come from the transactional outbox rather than from an in-process callback, which is what the outbox is for (PLAN.md section 9.4): the row is written in the same transaction as the message, so an event can never describe a delivery that rolled back, and a crash between commit and publish loses nothing because the row is still there on restart. The relay claims rows with SKIP LOCKED so a second instance can help without publishing anything twice. The hub fans events out per mailbox, keyed by the mailbox the session resolved to, so one account's arrivals are never visible to another even though they share the process. A subscriber that stops reading is dropped rather than allowed to stall the relay; it reconciles on reconnect. The client reconnects with capped exponential backoff and treats every successful connect as a resync, refetching the folder. Without that, a socket that dropped for a minute would leave the list quietly stale, which is worse than having no real-time updates at all. The upgrade goes through a Next rewrite, not the API route handler: a route handler sees a finished request and cannot upgrade a connection. Verified against the running stack - the handshake returns 101 through the Next server, and a message delivered on port 25 arrives as a frame on an already-open socket without the page asking for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
74 lines
2.2 KiB
TypeScript
74 lines
2.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
interface MailEvent {
|
|
type: string;
|
|
mailbox_id: string;
|
|
payload: Record<string, unknown>;
|
|
}
|
|
|
|
// Reconnection backs off so a server restart does not turn every open tab into
|
|
// a reconnect storm, and is capped so a tab left open still recovers promptly.
|
|
const INITIAL_RETRY_MS = 1000;
|
|
const MAX_RETRY_MS = 30000;
|
|
|
|
/**
|
|
* Subscribes to the server's event stream and calls onEvent for each push.
|
|
*
|
|
* The callback is also invoked once on every successful (re)connect, with a
|
|
* synthetic "resync" event: while the socket was down the mailbox may have
|
|
* changed, and reconnecting without refetching would leave the list quietly
|
|
* stale - which is worse than not having real-time updates at all.
|
|
*/
|
|
export function useMailEvents(onEvent: (event: MailEvent) => void): void {
|
|
// Held in a ref so a re-render with a new callback identity does not tear
|
|
// the socket down and reconnect.
|
|
const handler = useRef(onEvent);
|
|
handler.current = onEvent;
|
|
|
|
useEffect(() => {
|
|
let socket: WebSocket | null = null;
|
|
let retry = INITIAL_RETRY_MS;
|
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let closed = false;
|
|
|
|
const connect = () => {
|
|
if (closed) return;
|
|
|
|
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
|
|
socket = new WebSocket(`${scheme}://${window.location.host}/api/v1/events`);
|
|
|
|
socket.onopen = () => {
|
|
retry = INITIAL_RETRY_MS;
|
|
handler.current({ type: "resync", mailbox_id: "", payload: {} });
|
|
};
|
|
|
|
socket.onmessage = (message) => {
|
|
try {
|
|
handler.current(JSON.parse(message.data) as MailEvent);
|
|
} catch {
|
|
// A malformed frame is not worth tearing the connection down for.
|
|
}
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
if (closed) return;
|
|
retryTimer = setTimeout(connect, retry);
|
|
retry = Math.min(retry * 2, MAX_RETRY_MS);
|
|
};
|
|
|
|
// onerror is followed by onclose, which already schedules the retry.
|
|
socket.onerror = () => socket?.close();
|
|
};
|
|
|
|
connect();
|
|
|
|
return () => {
|
|
closed = true;
|
|
if (retryTimer) clearTimeout(retryTimer);
|
|
socket?.close();
|
|
};
|
|
}, []);
|
|
}
|