@supabase/gotrue-js 2.108.1 → 2.108.2-canary.1

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 (37) hide show
  1. package/dist/main/GoTrueClient.d.ts +19 -0
  2. package/dist/main/GoTrueClient.d.ts.map +1 -1
  3. package/dist/main/GoTrueClient.js +97 -8
  4. package/dist/main/GoTrueClient.js.map +1 -1
  5. package/dist/main/lib/constants.d.ts +8 -0
  6. package/dist/main/lib/constants.d.ts.map +1 -1
  7. package/dist/main/lib/constants.js +9 -1
  8. package/dist/main/lib/constants.js.map +1 -1
  9. package/dist/main/lib/fetch.d.ts.map +1 -1
  10. package/dist/main/lib/fetch.js +5 -3
  11. package/dist/main/lib/fetch.js.map +1 -1
  12. package/dist/main/lib/version.d.ts +1 -1
  13. package/dist/main/lib/version.d.ts.map +1 -1
  14. package/dist/main/lib/version.js +1 -1
  15. package/dist/main/lib/version.js.map +1 -1
  16. package/dist/module/GoTrueClient.d.ts +19 -0
  17. package/dist/module/GoTrueClient.d.ts.map +1 -1
  18. package/dist/module/GoTrueClient.js +98 -9
  19. package/dist/module/GoTrueClient.js.map +1 -1
  20. package/dist/module/lib/constants.d.ts +8 -0
  21. package/dist/module/lib/constants.d.ts.map +1 -1
  22. package/dist/module/lib/constants.js +8 -0
  23. package/dist/module/lib/constants.js.map +1 -1
  24. package/dist/module/lib/fetch.d.ts.map +1 -1
  25. package/dist/module/lib/fetch.js +5 -3
  26. package/dist/module/lib/fetch.js.map +1 -1
  27. package/dist/module/lib/version.d.ts +1 -1
  28. package/dist/module/lib/version.d.ts.map +1 -1
  29. package/dist/module/lib/version.js +1 -1
  30. package/dist/module/lib/version.js.map +1 -1
  31. package/dist/tsconfig.module.tsbuildinfo +1 -1
  32. package/dist/tsconfig.tsbuildinfo +1 -1
  33. package/package.json +1 -1
  34. package/src/GoTrueClient.ts +116 -13
  35. package/src/lib/constants.ts +9 -0
  36. package/src/lib/fetch.ts +5 -3
  37. package/src/lib/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supabase/gotrue-js",
3
- "version": "2.108.1",
3
+ "version": "2.108.2-canary.1",
4
4
  "private": false,
5
5
  "description": "Official SDK for Supabase Auth",
6
6
  "keywords": [
@@ -6,6 +6,7 @@ import {
6
6
  EXPIRY_MARGIN_MS,
7
7
  GOTRUE_URL,
8
8
  JWKS_TTL,
9
+ REFRESH_FAILURE_COOLDOWN_MS,
9
10
  STORAGE_KEY,
10
11
  } from './lib/constants'
11
12
  import {
@@ -288,6 +289,25 @@ export default class GoTrueClient {
288
289
  protected autoRefreshTickTimeout: ReturnType<typeof setTimeout> | null = null
289
290
  protected visibilityChangedCallback: (() => Promise<any>) | null = null
290
291
  protected refreshingDeferred: Deferred<CallRefreshTokenResult> | null = null
292
+ /**
293
+ * Cache of the most recent refresh failure, keyed by the refresh token
294
+ * that failed. Serial callers passing the *same* token within
295
+ * `REFRESH_FAILURE_COOLDOWN_MS` (including subsequent auto-refresh ticks)
296
+ * receive this cached result instead of firing another `/token` request.
297
+ * Callers passing a *different* token (token rotation pickup, explicit
298
+ * `setSession`/`refreshSession({ refresh_token })`, multi-account switch)
299
+ * bypass the cache and attempt a fresh refresh as they should.
300
+ * Cleared on any successful refresh (locally or via BroadcastChannel from
301
+ * another tab) and on `_removeSession`.
302
+ *
303
+ * Pairs with `refreshingDeferred`: concurrent callers share the in-flight
304
+ * promise, serial callers within the cooldown share the failure result.
305
+ */
306
+ protected lastRefreshFailure: {
307
+ refreshToken: string
308
+ result: CallRefreshTokenResult
309
+ expiresAt: number
310
+ } | null = null
291
311
  /**
292
312
  * Monotonic counter incremented at the top of `_removeSession`, before any
293
313
  * `await`. The commit guard inside `_callRefreshToken` captures this value
@@ -482,6 +502,13 @@ export default class GoTrueClient {
482
502
  this.broadcastChannel?.addEventListener('message', async (event) => {
483
503
  this._debug('received broadcast notification from other tab or client', event)
484
504
 
505
+ // Another tab successfully refreshed or signed in — any cached
506
+ // failure in this tab is stale and should not block the next
507
+ // refresh attempt.
508
+ if (event.data.event === 'TOKEN_REFRESHED' || event.data.event === 'SIGNED_IN') {
509
+ this.lastRefreshFailure = null
510
+ }
511
+
485
512
  try {
486
513
  await this._notifyAllSubscribers(event.data.event, event.data.session, false) // broadcast = false so we don't get an endless loop of messages
487
514
  } catch (error) {
@@ -2979,6 +3006,27 @@ export default class GoTrueClient {
2979
3006
 
2980
3007
  const { data: session, error } = await this._callRefreshToken(currentSession.refresh_token)
2981
3008
  if (error) {
3009
+ // Proactive-preserve mirror: `_callRefreshToken` keeps the session
3010
+ // in storage when refresh fails non-retryably but the access token
3011
+ // is still inside its real expiry window. Hand the caller the
3012
+ // still-valid session instead of translating the refresh error
3013
+ // into `session: null`. If the access token has actually expired,
3014
+ // the session is genuinely dead and the error stands. Explicit
3015
+ // refresh entry points (`refreshSession`, `setSession`)
3016
+ // intentionally bypass this fallback — they want to know the
3017
+ // refresh failed.
3018
+ const accessTokenStillValid = !!(
3019
+ currentSession.expires_at && currentSession.expires_at * 1000 > Date.now()
3020
+ )
3021
+ if (accessTokenStillValid) {
3022
+ // Race guard: a concurrent `signOut` may have cleared storage
3023
+ // during the refresh attempt. Don't hand back a session that no
3024
+ // longer exists on disk.
3025
+ const stillStored = (await getItemAsync(this.storage, this.storageKey)) as Session | null
3026
+ if (stillStored && stillStored.refresh_token === currentSession.refresh_token) {
3027
+ return this._returnResult({ data: { session: currentSession }, error: null })
3028
+ }
3029
+ }
2982
3030
  return this._returnResult({ data: { session: null }, error })
2983
3031
  }
2984
3032
 
@@ -4730,22 +4778,16 @@ export default class GoTrueClient {
4730
4778
  const { error } = await this._callRefreshToken(currentSession.refresh_token)
4731
4779
 
4732
4780
  if (error) {
4733
- // AuthRefreshDiscardedError means a concurrent signOut already
4734
- // cleared storage and fired SIGNED_OUT. Don't run _removeSession
4735
- // again here, or we'll emit a duplicate SIGNED_OUT.
4781
+ // `_callRefreshToken` is the single source of truth for refresh
4782
+ // outcomes: it removes the session itself when the access token
4783
+ // is actually expired, and preserves it when the token is still
4784
+ // valid (proactive-preserve). Don't second-guess that here — a
4785
+ // local `_removeSession` would emit a duplicate `SIGNED_OUT` on
4786
+ // genuine failures and undo the proactive-preserve at init time.
4736
4787
  if (isAuthRefreshDiscardedError(error)) {
4737
4788
  this._debug(debugName, 'refresh discarded by commit guard', error)
4738
4789
  } else {
4739
4790
  this._debug(debugName, 'refresh failed', error)
4740
-
4741
- if (!isAuthRetryableFetchError(error)) {
4742
- this._debug(
4743
- debugName,
4744
- 'refresh failed with a non-retryable error, removing the session',
4745
- error
4746
- )
4747
- await this._removeSession()
4748
- }
4749
4791
  }
4750
4792
  }
4751
4793
  }
@@ -4798,6 +4840,26 @@ export default class GoTrueClient {
4798
4840
  return this.refreshingDeferred.promise
4799
4841
  }
4800
4842
 
4843
+ // Serial failure cooldown: callers passing the *same* refresh token
4844
+ // after a recent failure receive the cached result instead of firing
4845
+ // another `/token` request. This caps the proactive-refresh storm
4846
+ // where every `getSession()` call inside the 90s EXPIRY_MARGIN_MS
4847
+ // window kept re-firing against the same broken refresh token during
4848
+ // outages. Concurrent callers already share `refreshingDeferred`; this
4849
+ // cache covers serial callers spaced across cooldown windows.
4850
+ //
4851
+ // Token-keyed so callers with a fresh refresh token (rotation pickup
4852
+ // from another tab, explicit `setSession`/`refreshSession({ refresh_token })`,
4853
+ // multi-account switch) bypass the cache and attempt a real refresh.
4854
+ if (
4855
+ this.lastRefreshFailure &&
4856
+ this.lastRefreshFailure.refreshToken === refreshToken &&
4857
+ Date.now() < this.lastRefreshFailure.expiresAt
4858
+ ) {
4859
+ this._debug('#_callRefreshToken()', 'returning cached failure (cooldown active)')
4860
+ return this.lastRefreshFailure.result
4861
+ }
4862
+
4801
4863
  // Refresh tokens are long-lived bearer credentials; do NOT include any
4802
4864
  // fragment of the token in the debug tag, even when `debug: true` is
4803
4865
  // enabled (logs may be forwarded to third-party services).
@@ -4881,6 +4943,10 @@ export default class GoTrueClient {
4881
4943
 
4882
4944
  const result = { data: data.session, error: null }
4883
4945
 
4946
+ // Refresh succeeded — clear any cached failure so the next caller
4947
+ // (including the auto-refresh ticker) attempts a real refresh again.
4948
+ this.lastRefreshFailure = null
4949
+
4884
4950
  this.refreshingDeferred.resolve(result)
4885
4951
 
4886
4952
  return result
@@ -4891,7 +4957,40 @@ export default class GoTrueClient {
4891
4957
  const result = { data: null, error }
4892
4958
 
4893
4959
  if (!isAuthRetryableFetchError(error)) {
4894
- await this._removeSession()
4960
+ // Proactive vs reactive distinction: a refresh fires whenever
4961
+ // the access token is within EXPIRY_MARGIN_MS of expiry. If the
4962
+ // access token is *still valid* at this moment, the refresh was
4963
+ // proactive and the existing session is still usable until its
4964
+ // real expiry — destroying it now would log out a user whose
4965
+ // access token works. If the access token has actually expired,
4966
+ // the refresh token is the only credential left and it just got
4967
+ // rejected — the session is genuinely dead. `__loadSession`
4968
+ // mirrors this distinction on the read path so callers see the
4969
+ // preserved session instead of `session: null`.
4970
+ const storedNow = (await getItemAsync(this.storage, this.storageKey)) as Session | null
4971
+ const accessTokenStillValid = !!(
4972
+ storedNow?.expires_at && storedNow.expires_at * 1000 > Date.now()
4973
+ )
4974
+
4975
+ if (accessTokenStillValid) {
4976
+ this._debug(
4977
+ debugName,
4978
+ 'proactive refresh failed, access token still valid — preserving session'
4979
+ )
4980
+ } else {
4981
+ await this._removeSession()
4982
+ }
4983
+ }
4984
+
4985
+ // Cache the failure so serial callers (and the next auto-refresh
4986
+ // tick) passing the same refresh token within the cooldown window
4987
+ // receive it synchronously instead of firing another `/token`
4988
+ // call. Set after the optional `_removeSession` above (which
4989
+ // clears the cache as part of teardown) so the cache survives.
4990
+ this.lastRefreshFailure = {
4991
+ refreshToken,
4992
+ result,
4993
+ expiresAt: Date.now() + REFRESH_FAILURE_COOLDOWN_MS,
4895
4994
  }
4896
4995
 
4897
4996
  this.refreshingDeferred?.resolve(result)
@@ -4995,6 +5094,10 @@ export default class GoTrueClient {
4995
5094
  this._sessionRemovalEpoch += 1
4996
5095
  this._debug('#_removeSession()')
4997
5096
 
5097
+ // The session is gone — no point holding on to a cached refresh failure
5098
+ // for a token that no longer exists. Synchronous, before any `await`.
5099
+ this.lastRefreshFailure = null
5100
+
4998
5101
  this.suppressGetSessionWarning = false
4999
5102
 
5000
5103
  await removeItemAsync(this.storage, this.storageKey)
@@ -12,6 +12,15 @@ export const AUTO_REFRESH_TICK_THRESHOLD = 3
12
12
  */
13
13
  export const EXPIRY_MARGIN_MS = AUTO_REFRESH_TICK_THRESHOLD * AUTO_REFRESH_TICK_DURATION_MS
14
14
 
15
+ /**
16
+ * After a refresh fails, serial callers (including the next auto-refresh
17
+ * tick) within this window receive the cached failure instead of firing
18
+ * another /token request. Two ticks: each outage burns at most one /token
19
+ * call per cooldown window. Cleared on any successful refresh (locally or
20
+ * via BroadcastChannel from another tab) and on `_removeSession`.
21
+ */
22
+ export const REFRESH_FAILURE_COOLDOWN_MS = 2 * AUTO_REFRESH_TICK_DURATION_MS
23
+
15
24
  export const GOTRUE_URL = 'http://localhost:9999'
16
25
  export const STORAGE_KEY = 'supabase.auth.token'
17
26
  export const AUDIENCE = ''
package/src/lib/fetch.ts CHANGED
@@ -69,10 +69,12 @@ const _getErrorMessage = (err: unknown): string => {
69
69
  return JSON.stringify(err)
70
70
  }
71
71
 
72
- // 502, 503, 504: Standard server/gateway errors
73
- // 520-524, 530: Cloudflare-specific error codes (web server down, connection timed out, etc.)
72
+ // 500, 501, 502, 503, 504: Standard server/gateway errors
73
+ // 520-529, 530: Cloudflare-specific error codes (web server down, connection timed out, etc.)
74
74
  // These are infrastructure errors and should not cause session invalidation.
75
- const NETWORK_ERROR_CODES = [502, 503, 504, 520, 521, 522, 523, 524, 530]
75
+ const NETWORK_ERROR_CODES = [
76
+ 500, 501, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 528, 529, 530,
77
+ ]
76
78
 
77
79
  export async function handleError(error: unknown) {
78
80
  if (!looksLikeFetchResponse(error)) {
@@ -4,4 +4,4 @@
4
4
  // - Debugging and support (identifying which version is running)
5
5
  // - Telemetry and logging (version reporting in errors/analytics)
6
6
  // - Ensuring build artifacts match the published package version
7
- export const version = '2.108.1'
7
+ export const version = '2.108.2-canary.1'