@bunbase-ae/js 2.5.1-next.164.09c2dd0 → 2.5.2-next.168.7184e15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/realtime.ts +123 -8
package/package.json
CHANGED
package/src/realtime.ts
CHANGED
|
@@ -19,6 +19,21 @@ import type { BunBaseRecord, RealtimeCallback, RealtimeEvent, UnsubscribeFn } fr
|
|
|
19
19
|
const INITIAL_RECONNECT_MS = 500;
|
|
20
20
|
const MAX_RECONNECT_MS = 30_000;
|
|
21
21
|
|
|
22
|
+
// Close codes emitted by the server for "no credentials / bad credentials"
|
|
23
|
+
// classes of failure. The SDK treats repeated instances of either as a signal
|
|
24
|
+
// that reconnecting is pointless until the caller sets an access token.
|
|
25
|
+
const WS_CLOSE_AUTH_TIMEOUT = 4001;
|
|
26
|
+
const WS_CLOSE_SUBSCRIBE_UNAUTHENTICATED = 4003;
|
|
27
|
+
|
|
28
|
+
// Bail-out threshold: after this many 4001|4003 closes within the window
|
|
29
|
+
// below — none of them followed by a successful subscribe ack — the SDK
|
|
30
|
+
// enters the "unauthenticated" sleeping state and stops reconnecting until
|
|
31
|
+
// the caller provides a token.
|
|
32
|
+
const UNAUTH_BAIL_CLOSES = 5;
|
|
33
|
+
const UNAUTH_BAIL_WINDOW_MS = 60_000;
|
|
34
|
+
|
|
35
|
+
export type ConnectionState = "connecting" | "connected" | "disconnected" | "unauthenticated";
|
|
36
|
+
|
|
22
37
|
// Internal message shapes from the server.
|
|
23
38
|
interface ServerChangeMessage<T = BunBaseRecord> {
|
|
24
39
|
type: "change";
|
|
@@ -82,6 +97,22 @@ export class RealtimeClient {
|
|
|
82
97
|
private subscriptionsListeners = new Set<(channels: string[]) => void>();
|
|
83
98
|
private presenceListeners = new Set<(channel: string, users: string[]) => void>();
|
|
84
99
|
|
|
100
|
+
// Unauthenticated-loop bailout: timestamps of recent 4001/4003 closes that
|
|
101
|
+
// were not followed by a successful subscribe ack. Trimmed to the last
|
|
102
|
+
// UNAUTH_BAIL_WINDOW_MS on each push. When >= UNAUTH_BAIL_CLOSES, the SDK
|
|
103
|
+
// enters the "unauthenticated" sleeping state.
|
|
104
|
+
private unauthCloseTimestamps: number[] = [];
|
|
105
|
+
// Set by the "ack" handler on any subscribe ack — proves the current
|
|
106
|
+
// connection is not stuck in the auth-reject loop. Cleared on reconnect.
|
|
107
|
+
private sawSuccessfulSubscribe = false;
|
|
108
|
+
// Public surface for consumers to inspect why reconnect stopped. Distinct
|
|
109
|
+
// from `connected` so applications can distinguish "offline, retrying"
|
|
110
|
+
// from "logged out, waiting for a token".
|
|
111
|
+
connectionState: ConnectionState = "disconnected";
|
|
112
|
+
// One-time warning flag: don't spam the error callback for every close in
|
|
113
|
+
// the bail-out loop. Reset when a token arrives via setTokens().
|
|
114
|
+
private warnedUnauth = false;
|
|
115
|
+
|
|
85
116
|
// Called when the server pushes an auth event indicating session state change.
|
|
86
117
|
// Receives null to indicate the session is no longer valid.
|
|
87
118
|
onAuthChange?: (user: null) => void;
|
|
@@ -89,6 +120,11 @@ export class RealtimeClient {
|
|
|
89
120
|
// Called when a presence response arrives: (channel, userIds[]).
|
|
90
121
|
onPresence?: (channel: string, users: string[]) => void;
|
|
91
122
|
|
|
123
|
+
// Called with a human-readable message when the SDK stops trying to
|
|
124
|
+
// reconnect (e.g., repeated auth rejections without a token). The SDK emits
|
|
125
|
+
// this once per entry into the unauthenticated state.
|
|
126
|
+
onError?: (message: string) => void;
|
|
127
|
+
|
|
92
128
|
constructor(
|
|
93
129
|
private wsUrl: string,
|
|
94
130
|
private readonly http: HttpClient,
|
|
@@ -97,9 +133,23 @@ export class RealtimeClient {
|
|
|
97
133
|
// Covers: login while connected, silent token refresh, logout.
|
|
98
134
|
// On logout (accessToken = null) no explicit unauth is needed — the
|
|
99
135
|
// server rejects further subscription events for the revoked session.
|
|
136
|
+
// On new token after an unauthenticated-loop bailout, reset state and
|
|
137
|
+
// reconnect immediately so the caller doesn't wait for the next backoff
|
|
138
|
+
// cycle to come out of sleep.
|
|
100
139
|
this.http.onTokenChange(({ accessToken }) => {
|
|
101
|
-
if (
|
|
102
|
-
this.
|
|
140
|
+
if (accessToken) {
|
|
141
|
+
const wasUnauth = this.connectionState === "unauthenticated";
|
|
142
|
+
if (wasUnauth) {
|
|
143
|
+
this.unauthCloseTimestamps = [];
|
|
144
|
+
this.warnedUnauth = false;
|
|
145
|
+
this.connectionState = "disconnected";
|
|
146
|
+
this.reconnectDelay = INITIAL_RECONNECT_MS;
|
|
147
|
+
if (this.channels.size > 0) this.connect();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (this.connected) {
|
|
151
|
+
this.send({ type: "auth", token: accessToken });
|
|
152
|
+
}
|
|
103
153
|
}
|
|
104
154
|
});
|
|
105
155
|
}
|
|
@@ -133,7 +183,10 @@ export class RealtimeClient {
|
|
|
133
183
|
// Send subscribe message if already connected.
|
|
134
184
|
if (this.connected) {
|
|
135
185
|
this.sendSubscribe(channel, this.channels.get(channel)?.options ?? {});
|
|
136
|
-
} else {
|
|
186
|
+
} else if (this.connectionState !== "unauthenticated") {
|
|
187
|
+
// Don't thrash when we've already decided reconnecting is pointless;
|
|
188
|
+
// stay in sleep until setTokens() wakes us up. The channel is still
|
|
189
|
+
// tracked and will be re-sent on the next successful connect.
|
|
137
190
|
this.connect();
|
|
138
191
|
}
|
|
139
192
|
|
|
@@ -227,6 +280,7 @@ export class RealtimeClient {
|
|
|
227
280
|
this.ws?.close();
|
|
228
281
|
this.ws = null;
|
|
229
282
|
this.connected = false;
|
|
283
|
+
this.connectionState = "disconnected";
|
|
230
284
|
}
|
|
231
285
|
|
|
232
286
|
// ─── Internal ──────────────────────────────────────────────────────────────
|
|
@@ -261,19 +315,28 @@ export class RealtimeClient {
|
|
|
261
315
|
}
|
|
262
316
|
|
|
263
317
|
this.intentionalClose = false;
|
|
318
|
+
this.connectionState = "connecting";
|
|
319
|
+
this.sawSuccessfulSubscribe = false;
|
|
264
320
|
this.ws = new WebSocket(`${this.wsUrl}/realtime`);
|
|
265
321
|
|
|
266
322
|
this.ws.onopen = () => {
|
|
267
323
|
this.connected = true;
|
|
324
|
+
this.connectionState = "connected";
|
|
268
325
|
this.reconnectDelay = INITIAL_RECONNECT_MS;
|
|
269
326
|
|
|
270
327
|
// Notify connection listeners and clear them (one-shot per connect).
|
|
271
328
|
for (const cb of this.connectListeners) cb();
|
|
272
329
|
this.connectListeners.clear();
|
|
273
330
|
|
|
274
|
-
// Authenticate if token available
|
|
331
|
+
// Authenticate if token available; otherwise send an explicit anonymous
|
|
332
|
+
// auth so the server clears its auth-timeout timer instead of closing
|
|
333
|
+
// this socket with 4001 after 10 s. Layer 2 of the #309 fix.
|
|
275
334
|
const token = this.http.getAccessToken();
|
|
276
|
-
if (token)
|
|
335
|
+
if (token) {
|
|
336
|
+
this.send({ type: "auth", token });
|
|
337
|
+
} else {
|
|
338
|
+
this.send({ type: "auth", anonymous: true });
|
|
339
|
+
}
|
|
277
340
|
|
|
278
341
|
// Re-subscribe all active channels (handles reconnect case).
|
|
279
342
|
for (const [channel, state] of this.channels) {
|
|
@@ -295,6 +358,15 @@ export class RealtimeClient {
|
|
|
295
358
|
return;
|
|
296
359
|
}
|
|
297
360
|
|
|
361
|
+
// A subscribe ack proves we can actually hold a subscription on this
|
|
362
|
+
// connection — reset the unauth-bailout state. (Auth-event acks flow
|
|
363
|
+
// through the "auth" branch below and carry no `channel` field.)
|
|
364
|
+
if (msg.type === "ack" && (msg as { channel?: string }).channel) {
|
|
365
|
+
this.sawSuccessfulSubscribe = true;
|
|
366
|
+
this.unauthCloseTimestamps = [];
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
298
370
|
if (msg.type === "subscriptions") {
|
|
299
371
|
const { channels } = msg as ServerSubscriptionsMessage;
|
|
300
372
|
for (const cb of this.subscriptionsListeners) cb(channels);
|
|
@@ -345,12 +417,55 @@ export class RealtimeClient {
|
|
|
345
417
|
for (const cb of state.callbacks) cb(realtimeEvent);
|
|
346
418
|
};
|
|
347
419
|
|
|
348
|
-
this.ws.onclose = () => {
|
|
420
|
+
this.ws.onclose = (ev: CloseEvent) => {
|
|
349
421
|
this.connected = false;
|
|
350
422
|
this.ws = null;
|
|
351
|
-
if (
|
|
352
|
-
this.
|
|
423
|
+
if (this.intentionalClose || this.channels.size === 0) {
|
|
424
|
+
this.connectionState = "disconnected";
|
|
425
|
+
return;
|
|
353
426
|
}
|
|
427
|
+
|
|
428
|
+
// Unauthenticated-loop bailout: count 4001|4003 closes against the
|
|
429
|
+
// rolling window only when the connection never got a subscribe ack.
|
|
430
|
+
// An older server that still emits 4001 (before Layer 1 shipped) or
|
|
431
|
+
// something exotic (proxy stripping the auth frame) counts the same as
|
|
432
|
+
// 4003 — both mean "this socket never held a valid subscription."
|
|
433
|
+
const isAuthFail =
|
|
434
|
+
ev.code === WS_CLOSE_AUTH_TIMEOUT || ev.code === WS_CLOSE_SUBSCRIBE_UNAUTHENTICATED;
|
|
435
|
+
if (isAuthFail && !this.sawSuccessfulSubscribe) {
|
|
436
|
+
const now = Date.now();
|
|
437
|
+
this.unauthCloseTimestamps = this.unauthCloseTimestamps.filter(
|
|
438
|
+
(t) => now - t < UNAUTH_BAIL_WINDOW_MS,
|
|
439
|
+
);
|
|
440
|
+
this.unauthCloseTimestamps.push(now);
|
|
441
|
+
|
|
442
|
+
if (
|
|
443
|
+
this.unauthCloseTimestamps.length >= UNAUTH_BAIL_CLOSES &&
|
|
444
|
+
this.http.getAccessToken() === null
|
|
445
|
+
) {
|
|
446
|
+
this.connectionState = "unauthenticated";
|
|
447
|
+
if (!this.warnedUnauth) {
|
|
448
|
+
this.warnedUnauth = true;
|
|
449
|
+
this.onError?.(
|
|
450
|
+
"Realtime reconnect paused: server is rejecting anonymous subscriptions. " +
|
|
451
|
+
"Call setTokens() to resume.",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
if (this.reconnectTimer !== null) {
|
|
455
|
+
clearTimeout(this.reconnectTimer);
|
|
456
|
+
this.reconnectTimer = null;
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
} else if (!isAuthFail) {
|
|
461
|
+
// A non-auth close means whatever the socket was doing, it wasn't
|
|
462
|
+
// stuck in the auth-reject loop — clear the counter so a later
|
|
463
|
+
// genuine outage doesn't trip the bailout.
|
|
464
|
+
this.unauthCloseTimestamps = [];
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.connectionState = "disconnected";
|
|
468
|
+
this.scheduleReconnect();
|
|
354
469
|
};
|
|
355
470
|
|
|
356
471
|
this.ws.onerror = () => {
|