@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/realtime.ts +123 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bunbase-ae/js",
3
- "version": "2.5.1-next.164.09c2dd0",
3
+ "version": "2.5.2-next.168.7184e15",
4
4
  "type": "module",
5
5
  "description": "TypeScript/JavaScript SDK for BunBase",
6
6
  "license": "UNLICENSED",
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 (this.connected && accessToken) {
102
- this.send({ type: "auth", token: accessToken });
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) this.send({ type: "auth", 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 (!this.intentionalClose && this.channels.size > 0) {
352
- this.scheduleReconnect();
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 = () => {