fix(web): stop authenticating WebSocket reconnect storm

Console evidence: on the authenticated layout, api.me() opened
wss://.../api/ws?token=<JWT> and the browser logged "WebSocket is closed
before the connection is established", then an uncaught
connection_closed rejection — a self-inflicted reconnect storm.

Root cause (two halves):
1. connectedToken was only assigned inside onopen, so during the CONNECTING
   window every other caller (RealtimeProvider's 2s poll, rpc's whenOpen)
   saw connectedToken !== token and called openWith again, tearing down the
   socket it had just dialled.
2. Replacing a socket left its late onclose to run onSocketClosed, which nulled
   this.socket and connectedToken — clobbering the successor and firing a
   spurious reconnect + rejected openPromise.

Fix:
- Set connectedToken the moment we commit to a dial (in openWith), so
  concurrent callers wait on the same openPromise instead of re-dialling.
- Guard onopen/onmessage/onclose with `this.socket !== socket` so a replaced
  socket's late callbacks are ignored and cannot clobber its successor.

Anonymous login escaped this (both tokens null); an authenticated connection
(a real JWT) triggered it, matching the reported failure.
This commit is contained in:
mjalmoudy94
2026-10-13 16:07:37 +03:30
parent 22034fd90a
commit e50c86c451
+20 -2
View File
@@ -142,12 +142,21 @@ export class RealtimeClient {
: websocketUrl();
const socket = new WebSocket(url);
this.socket = socket;
// Record the token of THIS attempt immediately, not only on open. While the
// socket is CONNECTING, ``connectedToken`` must already equal the token we
// are dialling, so concurrent callers (the provider's poll, another RPC)
// wait on ``openPromise`` instead of tearing the socket down and dialling a
// second one — which is what produced "closed before established" + a
// reconnect storm.
this.connectedToken = token;
this.openPromise = new Promise<void>((resolve, reject) => {
let settled = false;
socket.onopen = () => {
// This socket may have been replaced (token change) before its handshake
// finished; if so, its late "open" belongs to a link we already dropped.
if (this.socket !== socket) return;
settled = true;
this.connectedToken = token;
this.lastActivityAt = Date.now();
this.reconnectAttempts = 0;
this.setStatus("live");
@@ -160,8 +169,17 @@ export class RealtimeClient {
}
resolve();
};
socket.onmessage = (message) => this.handleFrame(message.data);
socket.onmessage = (message) => {
// Ignore frames from a socket that was already replaced (e.g. a token
// change opened a new one): its late frames belong to a dead link.
if (this.socket !== socket) return;
this.handleFrame(message.data);
};
socket.onclose = () => {
// A replaced socket's late close must not clobber its successor: without
// this guard, closing the old socket nulls ``this.socket``/token and
// fires a spurious reconnect -- the "closed before established" storm.
if (this.socket !== socket) return;
if (!settled) {
reject(
new ApiError(0, "connection_closed", "اتصال پیش از برقراری قطع شد."),