The browser now keeps its half of the one event shape logs.py defines: lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring with the server's level names and severity numbers. Nothing leaves the browser yet; sending is the consented diagnostics level (S5). - lib/http.ts `send` is the one fetch: every request carries an X-Request-Id and becomes one http.request event. The server echoes the matched route template in X-Route (only for a matched route, so a raw path with a value in it is never sent back); without one the client templates the path itself. req, beacon, the upload XHR, the public watch page and the legal pages all go through it. - Hooks: app start snapshot, uncaught errors and rejections, History push/replace/popstate (a scroll-only replace is not an event; parameter values only at DEBUG, typed search only at TRACE), notifications, the error dialog, the crash boundary, failed queries and mutations (key head only), and the messages socket (lifecycle, frame types at DEBUG). - Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json runs against both redact() implementations. - Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging module, console to none; a contract test proves the rules fire. Fixes a socket takeover: the close of a socket released by the last subscriber cleared the newer socket a quick re-subscribe had opened and scheduled a third, leaving two live sockets that delivered every message twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
// Live message delivery over a WebSocket. The server pushes a {type:"message", message} frame
|
|
// to all of a user's open tabs when a message is stored; subscribers react (e.g. refetch the
|
|
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
|
|
// safety net if the socket is down.
|
|
import { getActiveAccount, type Message, type MessageUser } from "./api";
|
|
import { log } from "./log";
|
|
|
|
// What the client log records of the socket: its lifecycle, and at DEBUG each frame's TYPE. Never a
|
|
// frame's content — chat plaintext stays out of every log structurally, not by redaction. The path
|
|
// is fixed, without the `?account=` query.
|
|
const WS_PATH = "/api/messages/ws";
|
|
|
|
// A pushed message plus both parties, so the dock can open/flash the right window.
|
|
interface IncomingMessage {
|
|
message: Message;
|
|
from?: MessageUser;
|
|
to?: MessageUser;
|
|
}
|
|
type Handler = (e: IncomingMessage) => void;
|
|
type UnreadHandler = () => void;
|
|
|
|
const handlers = new Set<Handler>();
|
|
const unreadHandlers = new Set<UnreadHandler>();
|
|
let ws: WebSocket | null = null;
|
|
let started = false;
|
|
let backoff = 1000;
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function url(): string {
|
|
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
// A WebSocket can't carry the X-Siftlode-Account header, so the per-tab account rides in the
|
|
// query string (validated against the browser wallet server-side). The account is read once
|
|
// here: switching accounts always goes through location.reload() (NavSidebar), which rebuilds
|
|
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
|
|
const account = getActiveAccount();
|
|
const qs = account != null ? `?account=${account}` : "";
|
|
return `${proto}://${location.host}${WS_PATH}${qs}`;
|
|
}
|
|
|
|
function open() {
|
|
let sock: WebSocket;
|
|
try {
|
|
sock = new WebSocket(url());
|
|
} catch (e) {
|
|
log.warn("ws.failed", "could not open the socket", { path: WS_PATH, message: String(e) });
|
|
schedule();
|
|
return;
|
|
}
|
|
ws = sock;
|
|
// Every handler checks it still belongs to the CURRENT socket. A socket closed by the last
|
|
// subscriber leaving closes asynchronously: when a subscriber came back in between (a remount,
|
|
// StrictMode's double effect) a newer socket is already open, and the old one's close used to
|
|
// clear that newer reference and schedule a THIRD — two live sockets, every message delivered twice.
|
|
sock.onopen = () => {
|
|
if (ws !== sock) return;
|
|
log.info("ws.open", "socket open", { path: WS_PATH });
|
|
backoff = 1000;
|
|
};
|
|
sock.onmessage = (ev) => {
|
|
if (ws !== sock) return;
|
|
try {
|
|
const data = JSON.parse(ev.data);
|
|
log.debug("ws.frame", "socket frame", { path: WS_PATH, type: String(data?.type) });
|
|
if (data?.type === "message" && data.message) {
|
|
const e: IncomingMessage = {
|
|
message: data.message as Message,
|
|
from: data.from,
|
|
to: data.to,
|
|
};
|
|
for (const h of handlers) h(e);
|
|
} else if (data?.type === "unread") {
|
|
// Sent to a user's OTHER tabs when they read a thread elsewhere, so the badge updates live.
|
|
for (const h of unreadHandlers) h();
|
|
}
|
|
} catch {
|
|
/* ignore malformed frames */
|
|
}
|
|
};
|
|
sock.onclose = (ev) => {
|
|
const current = ws === sock;
|
|
// A socket we let go of (the last subscriber left) closing is routine; the current one closing
|
|
// is the server or the network going away, and the reconnect that follows is worth seeing.
|
|
(current ? log.warn : log.info)("ws.close", "socket closed", {
|
|
path: WS_PATH,
|
|
code: ev.code,
|
|
clean: ev.wasClean,
|
|
reconnect_ms: current && started ? backoff : null,
|
|
});
|
|
if (!current) return;
|
|
ws = null;
|
|
if (started) schedule();
|
|
};
|
|
sock.onerror = () => {
|
|
sock.close();
|
|
};
|
|
}
|
|
|
|
function schedule() {
|
|
if (reconnectTimer) return;
|
|
reconnectTimer = setTimeout(() => {
|
|
reconnectTimer = null;
|
|
if (started) open();
|
|
}, backoff);
|
|
backoff = Math.min(backoff * 2, 30000);
|
|
}
|
|
|
|
// Subscribe to live messages. Opens the socket on the first subscriber and closes it when the
|
|
// last one leaves.
|
|
export function onMessage(h: Handler): () => void {
|
|
handlers.add(h);
|
|
if (!started) {
|
|
started = true;
|
|
open();
|
|
}
|
|
return () => {
|
|
handlers.delete(h);
|
|
if (handlers.size === 0) {
|
|
started = false;
|
|
if (reconnectTimer) {
|
|
clearTimeout(reconnectTimer);
|
|
reconnectTimer = null;
|
|
}
|
|
ws?.close();
|
|
ws = null;
|
|
}
|
|
};
|
|
}
|
|
|
|
// Subscribe to "your unread changed elsewhere" pings (a read on another tab/device). The socket is
|
|
// already opened/closed by onMessage subscribers (the app-wide ChatDock keeps one), so this only
|
|
// registers a callback — it does not open the socket on its own.
|
|
export function onUnread(h: UnreadHandler): () => void {
|
|
unreadHandlers.add(h);
|
|
return () => {
|
|
unreadHandlers.delete(h);
|
|
};
|
|
}
|