@supabase/auth-js 2.106.2-canary.1 → 2.107.0-beta.0

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 (49) hide show
  1. package/AGENTS.md +11 -0
  2. package/dist/main/GoTrueClient.d.ts +68 -14
  3. package/dist/main/GoTrueClient.d.ts.map +1 -1
  4. package/dist/main/GoTrueClient.js +331 -107
  5. package/dist/main/GoTrueClient.js.map +1 -1
  6. package/dist/main/lib/errors.d.ts +24 -0
  7. package/dist/main/lib/errors.d.ts.map +1 -1
  8. package/dist/main/lib/errors.js +31 -1
  9. package/dist/main/lib/errors.js.map +1 -1
  10. package/dist/main/lib/locks.d.ts +28 -34
  11. package/dist/main/lib/locks.d.ts.map +1 -1
  12. package/dist/main/lib/locks.js +28 -34
  13. package/dist/main/lib/locks.js.map +1 -1
  14. package/dist/main/lib/types.d.ts +16 -27
  15. package/dist/main/lib/types.d.ts.map +1 -1
  16. package/dist/main/lib/types.js.map +1 -1
  17. package/dist/main/lib/version.d.ts +1 -1
  18. package/dist/main/lib/version.d.ts.map +1 -1
  19. package/dist/main/lib/version.js +1 -1
  20. package/dist/main/lib/version.js.map +1 -1
  21. package/dist/module/GoTrueClient.d.ts +68 -14
  22. package/dist/module/GoTrueClient.d.ts.map +1 -1
  23. package/dist/module/GoTrueClient.js +333 -109
  24. package/dist/module/GoTrueClient.js.map +1 -1
  25. package/dist/module/lib/errors.d.ts +24 -0
  26. package/dist/module/lib/errors.d.ts.map +1 -1
  27. package/dist/module/lib/errors.js +28 -0
  28. package/dist/module/lib/errors.js.map +1 -1
  29. package/dist/module/lib/locks.d.ts +28 -34
  30. package/dist/module/lib/locks.d.ts.map +1 -1
  31. package/dist/module/lib/locks.js +28 -34
  32. package/dist/module/lib/locks.js.map +1 -1
  33. package/dist/module/lib/types.d.ts +16 -27
  34. package/dist/module/lib/types.d.ts.map +1 -1
  35. package/dist/module/lib/types.js.map +1 -1
  36. package/dist/module/lib/version.d.ts +1 -1
  37. package/dist/module/lib/version.d.ts.map +1 -1
  38. package/dist/module/lib/version.js +1 -1
  39. package/dist/module/lib/version.js.map +1 -1
  40. package/dist/tsconfig.module.tsbuildinfo +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/migrations/README.md +25 -0
  43. package/migrations/lockless-coordination.md +89 -0
  44. package/package.json +4 -2
  45. package/src/GoTrueClient.ts +397 -137
  46. package/src/lib/errors.ts +32 -0
  47. package/src/lib/locks.ts +29 -34
  48. package/src/lib/types.ts +16 -27
  49. package/src/lib/version.ts +1 -1
@@ -25,10 +25,16 @@ const DEFAULT_OPTIONS = {
25
25
  debug: false,
26
26
  hasCustomAuthorizationHeader: false,
27
27
  throwOnError: false,
28
- lockAcquireTimeout: 5000, // 5 seconds
28
+ lockAcquireTimeout: 5000, // 5 seconds. Only used when a custom `lock` is supplied. TODO(v3): remove.
29
29
  skipAutoInitialize: false,
30
30
  experimental: {},
31
31
  };
32
+ /**
33
+ * No-op lock used internally as a placeholder. Kept so older test setups that
34
+ * inject this exact reference do not break; new code never sees it because
35
+ * `this.lock` stays `null` when no custom lock is supplied (lockless path).
36
+ * TODO(v3): remove with the legacy lock path.
37
+ */
32
38
  async function lockNoOp(name, acquireTimeout, fn) {
33
39
  return await fn();
34
40
  }
@@ -82,7 +88,7 @@ class GoTrueClient {
82
88
  * ```
83
89
  */
84
90
  constructor(options) {
85
- var _a, _b, _c, _d;
91
+ var _a, _b, _c;
86
92
  /**
87
93
  * @experimental
88
94
  */
@@ -93,6 +99,14 @@ class GoTrueClient {
93
99
  this.autoRefreshTickTimeout = null;
94
100
  this.visibilityChangedCallback = null;
95
101
  this.refreshingDeferred = null;
102
+ /**
103
+ * Monotonic counter incremented at the top of `_removeSession`, before any
104
+ * `await`. The commit guard inside `_callRefreshToken` captures this value
105
+ * before `_saveSession` and re-checks it after, so a `signOut` that
106
+ * interleaves inside `_saveSession`'s storage-write awaits is still caught
107
+ * (the post-fetch storage snapshot alone misses that window).
108
+ */
109
+ this._sessionRemovalEpoch = 0;
96
110
  /**
97
111
  * Keeps track of the async client initialization.
98
112
  * When null or not yet resolved the auth state is `unknown`
@@ -103,6 +117,13 @@ class GoTrueClient {
103
117
  this.detectSessionInUrl = true;
104
118
  this.hasCustomAuthorizationHeader = false;
105
119
  this.suppressGetSessionWarning = false;
120
+ /**
121
+ * Custom lock function passed via `settings.lock`. When non-null, every auth
122
+ * operation runs inside `_acquireLock`. When null (the default), the client
123
+ * uses its lockless coordination (refresh single-flight + commit guard).
124
+ * TODO(v3): remove along with the legacy lock path.
125
+ */
126
+ this.lock = null;
106
127
  this.lockAcquired = false;
107
128
  this.pendingInLock = [];
108
129
  /**
@@ -137,21 +158,22 @@ class GoTrueClient {
137
158
  this.url = settings.url;
138
159
  this.headers = settings.headers;
139
160
  this.fetch = (0, helpers_1.resolveFetch)(settings.fetch);
140
- this.lock = settings.lock || lockNoOp;
141
161
  this.detectSessionInUrl = settings.detectSessionInUrl;
142
162
  this.flowType = settings.flowType;
143
163
  this.hasCustomAuthorizationHeader = settings.hasCustomAuthorizationHeader;
144
164
  this.throwOnError = settings.throwOnError;
165
+ // Always wire `lockAcquireTimeout` even on the lockless path: consumers
166
+ // (including supabase-js tests) read it off the client to verify option
167
+ // flow-through.
145
168
  this.lockAcquireTimeout = settings.lockAcquireTimeout;
146
- if (settings.lock) {
169
+ // TODO(v3): remove. Legacy opt-in path preserved for backwards
170
+ // compatibility with callers passing a custom `lock` (typically React
171
+ // Native `processLock` or Node multi-process setups). When `settings.lock`
172
+ // is null the client uses its lockless coordination — no `navigator.locks`
173
+ // by default, no implicit `processLock`.
174
+ if (settings.lock != null) {
147
175
  this.lock = settings.lock;
148
176
  }
149
- else if (this.persistSession && (0, helpers_1.isBrowser)() && ((_c = globalThis === null || globalThis === void 0 ? void 0 : globalThis.navigator) === null || _c === void 0 ? void 0 : _c.locks)) {
150
- this.lock = locks_1.navigatorLock;
151
- }
152
- else {
153
- this.lock = lockNoOp;
154
- }
155
177
  if (!this.jwks) {
156
178
  this.jwks = { keys: [] };
157
179
  this.jwks_cached_at = Number.MIN_SAFE_INTEGER;
@@ -210,7 +232,7 @@ class GoTrueClient {
210
232
  catch (e) {
211
233
  console.error('Failed to create a new BroadcastChannel, multi-tab state changes will not be available', e);
212
234
  }
213
- (_d = this.broadcastChannel) === null || _d === void 0 ? void 0 : _d.addEventListener('message', async (event) => {
235
+ (_c = this.broadcastChannel) === null || _c === void 0 ? void 0 : _c.addEventListener('message', async (event) => {
214
236
  this._debug('received broadcast notification from other tab or client', event);
215
237
  try {
216
238
  await this._notifyAllSubscribers(event.data.event, event.data.session, false); // broadcast = false so we don't get an endless loop of messages
@@ -268,9 +290,13 @@ class GoTrueClient {
268
290
  return await this.initializePromise;
269
291
  }
270
292
  this.initializePromise = (async () => {
271
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
272
- return await this._initialize();
273
- });
293
+ if (this.lock != null) {
294
+ // TODO(v3): remove legacy lock path
295
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
296
+ return await this._initialize();
297
+ });
298
+ }
299
+ return await this._initialize();
274
300
  })();
275
301
  return await this.initializePromise;
276
302
  }
@@ -1121,9 +1147,13 @@ class GoTrueClient {
1121
1147
  */
1122
1148
  async exchangeCodeForSession(authCode) {
1123
1149
  await this.initializePromise;
1124
- return this._acquireLock(this.lockAcquireTimeout, async () => {
1125
- return this._exchangeCodeForSession(authCode);
1126
- });
1150
+ if (this.lock != null) {
1151
+ // TODO(v3): remove legacy lock path
1152
+ return this._acquireLock(this.lockAcquireTimeout, async () => {
1153
+ return this._exchangeCodeForSession(authCode);
1154
+ });
1155
+ }
1156
+ return this._exchangeCodeForSession(authCode);
1127
1157
  }
1128
1158
  /**
1129
1159
  * Signs in a user by verifying a message signed by the user's private key.
@@ -2027,9 +2057,13 @@ class GoTrueClient {
2027
2057
  */
2028
2058
  async reauthenticate() {
2029
2059
  await this.initializePromise;
2030
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2031
- return await this._reauthenticate();
2032
- });
2060
+ if (this.lock != null) {
2061
+ // TODO(v3): remove legacy lock path
2062
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2063
+ return await this._reauthenticate();
2064
+ });
2065
+ }
2066
+ return await this._reauthenticate();
2033
2067
  }
2034
2068
  async _reauthenticate() {
2035
2069
  try {
@@ -2172,7 +2206,7 @@ class GoTrueClient {
2172
2206
  * - If the session's access token is expired or is about to expire, this method will use the refresh token to refresh the session.
2173
2207
  * - When using in a browser, or you've called `startAutoRefresh()` in your environment (React Native, etc.) this function always returns a valid access token without refreshing the session itself, as this is done in the background. This function returns very fast.
2174
2208
  * - **IMPORTANT SECURITY NOTICE:** If using an insecure storage medium, such as cookies or request headers, the user object returned by this function **must not be trusted**. Always verify the JWT using `getClaims()` or your own JWT verification library to securely establish the user's identity and access. You can also use `getUser()` to fetch the user object directly from the Auth server for this purpose.
2175
- * - When using in a browser, this function is synchronized across all tabs using the [LockManager](https://developer.mozilla.org/en-US/docs/Web/API/LockManager) API. In other environments make sure you've defined a proper `lock` property, if necessary, to make sure there are no race conditions while the session is being refreshed.
2209
+ * - Cross-tab refresh races are handled by the GoTrue server (the rotated token from the first tab is returned to subsequent tabs via the parent-of-active mechanism), so no client-side serialization is needed.
2176
2210
  *
2177
2211
  * @example Get the session data
2178
2212
  * ```js
@@ -2239,15 +2273,24 @@ class GoTrueClient {
2239
2273
  */
2240
2274
  async getSession() {
2241
2275
  await this.initializePromise;
2242
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2243
- return this._useSession(async (result) => {
2244
- return result;
2276
+ if (this.lock != null) {
2277
+ // TODO(v3): remove legacy lock path
2278
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2279
+ return this._useSession(async (result) => {
2280
+ return result;
2281
+ });
2245
2282
  });
2283
+ }
2284
+ return await this._useSession(async (result) => {
2285
+ return result;
2246
2286
  });
2247
- return result;
2248
2287
  }
2249
2288
  /**
2250
2289
  * Acquires a global lock based on the storage key.
2290
+ *
2291
+ * TODO(v3): remove along with the legacy lock path. Only called when
2292
+ * `this.lock` is non-null (custom lock supplied via constructor). The
2293
+ * default lockless path bypasses this entirely.
2251
2294
  */
2252
2295
  async _acquireLock(acquireTimeout, fn) {
2253
2296
  this._debug('#_acquireLock', 'begin', acquireTimeout);
@@ -2303,15 +2346,17 @@ class GoTrueClient {
2303
2346
  }
2304
2347
  }
2305
2348
  /**
2306
- * Use instead of {@link #getSession} inside the library. It is
2307
- * semantically usually what you want, as getting a session involves some
2308
- * processing afterwards that requires only one client operating on the
2309
- * session at once across multiple tabs or processes.
2349
+ * Use instead of {@link #getSession} inside the library. Loads the session
2350
+ * via `__loadSession` (which may trigger a refresh if the access token is
2351
+ * within the expiry margin) and runs `fn` with the result.
2310
2352
  */
2311
2353
  async _useSession(fn) {
2312
2354
  this._debug('#_useSession', 'begin');
2313
2355
  try {
2314
- // the use of __loadSession here is the only correct use of the function!
2356
+ // Concurrent callers may both reach __loadSession; storage reads are
2357
+ // idempotent, and the only write path inside it (refresh) is
2358
+ // single-flighted downstream by `refreshingDeferred` in
2359
+ // `_callRefreshToken`. No serialization is needed at this layer.
2315
2360
  const result = await this.__loadSession();
2316
2361
  return await fn(result);
2317
2362
  }
@@ -2326,7 +2371,8 @@ class GoTrueClient {
2326
2371
  */
2327
2372
  async __loadSession() {
2328
2373
  this._debug('#__loadSession()', 'begin');
2329
- if (!this.lockAcquired) {
2374
+ if (this.lock != null && !this.lockAcquired) {
2375
+ // TODO(v3): remove. Only meaningful on the legacy lock path.
2330
2376
  this._debug('#__loadSession()', 'used outside of an acquired lock!', new Error().stack);
2331
2377
  }
2332
2378
  try {
@@ -2469,9 +2515,16 @@ class GoTrueClient {
2469
2515
  return await this._getUser(jwt);
2470
2516
  }
2471
2517
  await this.initializePromise;
2472
- const result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2473
- return await this._getUser();
2474
- });
2518
+ let result;
2519
+ if (this.lock != null) {
2520
+ // TODO(v3): remove legacy lock path
2521
+ result = await this._acquireLock(this.lockAcquireTimeout, async () => {
2522
+ return await this._getUser();
2523
+ });
2524
+ }
2525
+ else {
2526
+ result = await this._getUser();
2527
+ }
2475
2528
  if (result.data.user) {
2476
2529
  this.suppressGetSessionWarning = true;
2477
2530
  }
@@ -2632,9 +2685,13 @@ class GoTrueClient {
2632
2685
  */
2633
2686
  async updateUser(attributes, options = {}) {
2634
2687
  await this.initializePromise;
2635
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2636
- return await this._updateUser(attributes, options);
2637
- });
2688
+ if (this.lock != null) {
2689
+ // TODO(v3): remove legacy lock path
2690
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2691
+ return await this._updateUser(attributes, options);
2692
+ });
2693
+ }
2694
+ return await this._updateUser(attributes, options);
2638
2695
  }
2639
2696
  async _updateUser(attributes, options = {}) {
2640
2697
  try {
@@ -2803,9 +2860,13 @@ class GoTrueClient {
2803
2860
  */
2804
2861
  async setSession(currentSession) {
2805
2862
  await this.initializePromise;
2806
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2807
- return await this._setSession(currentSession);
2808
- });
2863
+ if (this.lock != null) {
2864
+ // TODO(v3): remove legacy lock path
2865
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
2866
+ return await this._setSession(currentSession);
2867
+ });
2868
+ }
2869
+ return await this._setSession(currentSession);
2809
2870
  }
2810
2871
  async _setSession(currentSession) {
2811
2872
  try {
@@ -2983,9 +3044,13 @@ class GoTrueClient {
2983
3044
  */
2984
3045
  async refreshSession(currentSession) {
2985
3046
  await this.initializePromise;
2986
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
2987
- return await this._refreshSession(currentSession);
2988
- });
3047
+ if (this.lock != null) {
3048
+ // TODO(v3): remove legacy lock path
3049
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3050
+ return await this._refreshSession(currentSession);
3051
+ });
3052
+ }
3053
+ return await this._refreshSession(currentSession);
2989
3054
  }
2990
3055
  async _refreshSession(currentSession) {
2991
3056
  try {
@@ -3175,9 +3240,13 @@ class GoTrueClient {
3175
3240
  */
3176
3241
  async signOut(options = { scope: 'global' }) {
3177
3242
  await this.initializePromise;
3178
- return await this._acquireLock(this.lockAcquireTimeout, async () => {
3179
- return await this._signOut(options);
3180
- });
3243
+ if (this.lock != null) {
3244
+ // TODO(v3): remove legacy lock path
3245
+ return await this._acquireLock(this.lockAcquireTimeout, async () => {
3246
+ return await this._signOut(options);
3247
+ });
3248
+ }
3249
+ return await this._signOut(options);
3181
3250
  }
3182
3251
  async _signOut({ scope } = { scope: 'global' }) {
3183
3252
  return await this._useSession(async (result) => {
@@ -3213,18 +3282,8 @@ class GoTrueClient {
3213
3282
  * - Subscribes to important events occurring on the user's session.
3214
3283
  * - Use on the frontend/client. It is less useful on the server.
3215
3284
  * - Events are emitted across tabs to keep your application's UI up-to-date. Some events can fire very frequently, based on the number of tabs open. Use a quick and efficient callback function, and defer or debounce as many operations as you can to be performed outside of the callback.
3216
- * - **Important:** A callback can be an `async` function and it runs synchronously during the processing of the changes causing the event. You can easily create a dead-lock by using `await` on a call to another method of the Supabase library.
3217
- * - Avoid using `async` functions as callbacks.
3218
- * - Limit the number of `await` calls in `async` callbacks.
3219
- * - Do not use other Supabase functions in the callback function. If you must, dispatch the functions once the callback has finished executing. Use this as a quick way to achieve this:
3220
- * ```js
3221
- * supabase.auth.onAuthStateChange((event, session) => {
3222
- * setTimeout(async () => {
3223
- * // await on other Supabase function here
3224
- * // this runs right after the callback has finished
3225
- * }, 0)
3226
- * })
3227
- * ```
3285
+ * - Callbacks can be `async` and can safely call other Supabase auth methods (`getUser`, `setSession`, etc.) from inside the callback.
3286
+ * - Keep callbacks quick. Events are awaited in order, so a slow callback delays subsequent events to subscribers in this tab.
3228
3287
  * - Emitted events:
3229
3288
  * - `INITIAL_SESSION`
3230
3289
  * - Emitted right after the Supabase client is constructed and the initial session from storage is loaded.
@@ -3407,9 +3466,15 @@ class GoTrueClient {
3407
3466
  this.stateChangeEmitters.set(id, subscription);
3408
3467
  (async () => {
3409
3468
  await this.initializePromise;
3410
- await this._acquireLock(this.lockAcquireTimeout, async () => {
3411
- this._emitInitialSession(id);
3412
- });
3469
+ if (this.lock != null) {
3470
+ // TODO(v3): remove legacy lock path
3471
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
3472
+ this._emitInitialSession(id);
3473
+ });
3474
+ }
3475
+ else {
3476
+ await this._emitInitialSession(id);
3477
+ }
3413
3478
  })();
3414
3479
  return { data: { subscription } };
3415
3480
  }
@@ -3751,7 +3816,10 @@ class GoTrueClient {
3751
3816
  * @param refreshToken A valid refresh token that was returned on login.
3752
3817
  */
3753
3818
  async _refreshAccessToken(refreshToken) {
3754
- const debugName = `#_refreshAccessToken(${refreshToken.substring(0, 5)}...)`;
3819
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
3820
+ // fragment of the token in the debug tag, even when `debug: true` is
3821
+ // enabled (logs may be forwarded to third-party services).
3822
+ const debugName = `#_refreshAccessToken()`;
3755
3823
  this._debug(debugName, 'begin');
3756
3824
  try {
3757
3825
  const startedAt = Date.now();
@@ -3858,10 +3926,18 @@ class GoTrueClient {
3858
3926
  if (this.autoRefreshToken && currentSession.refresh_token) {
3859
3927
  const { error } = await this._callRefreshToken(currentSession.refresh_token);
3860
3928
  if (error) {
3861
- console.error(error);
3862
- if (!(0, errors_1.isAuthRetryableFetchError)(error)) {
3863
- this._debug(debugName, 'refresh failed with a non-retryable error, removing the session', error);
3864
- await this._removeSession();
3929
+ // AuthRefreshDiscardedError means a concurrent signOut already
3930
+ // cleared storage and fired SIGNED_OUT. Don't run _removeSession
3931
+ // again here, or we'll emit a duplicate SIGNED_OUT.
3932
+ if ((0, errors_1.isAuthRefreshDiscardedError)(error)) {
3933
+ this._debug(debugName, 'refresh discarded by commit guard', error);
3934
+ }
3935
+ else {
3936
+ console.error(error);
3937
+ if (!(0, errors_1.isAuthRetryableFetchError)(error)) {
3938
+ this._debug(debugName, 'refresh failed with a non-retryable error, removing the session', error);
3939
+ await this._removeSession();
3940
+ }
3865
3941
  }
3866
3942
  }
3867
3943
  }
@@ -3910,16 +3986,69 @@ class GoTrueClient {
3910
3986
  if (this.refreshingDeferred) {
3911
3987
  return this.refreshingDeferred.promise;
3912
3988
  }
3913
- const debugName = `#_callRefreshToken(${refreshToken.substring(0, 5)}...)`;
3989
+ // Refresh tokens are long-lived bearer credentials; do NOT include any
3990
+ // fragment of the token in the debug tag, even when `debug: true` is
3991
+ // enabled (logs may be forwarded to third-party services).
3992
+ const debugName = `#_callRefreshToken()`;
3914
3993
  this._debug(debugName, 'begin');
3915
3994
  try {
3916
3995
  this.refreshingDeferred = new helpers_1.Deferred();
3996
+ // Snapshot storage before the fetch. The commit guard discards the
3997
+ // rotated tokens only when a non-null pre-fetch snapshot changed under
3998
+ // us — typical case: a concurrent `signOut` ran `_removeSession`, or
3999
+ // another tab's refresh rewrote the slot. Callers passing
4000
+ // externally-sourced tokens (SSR cookie handoff, multi-account
4001
+ // switching, `setSession`/`refreshSession({ refresh_token })`) may
4002
+ // start from a null snapshot OR from a non-null snapshot whose
4003
+ // refresh_token differs from the one they're hydrating; in both
4004
+ // cases the guard fires only when storage was *modified between
4005
+ // snapshots*, not when the input token disagrees with what's stored.
4006
+ const storedAtStart = (await (0, helpers_1.getItemAsync)(this.storage, this.storageKey));
3917
4007
  const { data, error } = await this._refreshAccessToken(refreshToken);
3918
4008
  if (error)
3919
4009
  throw error;
3920
4010
  if (!data.session)
3921
4011
  throw new errors_1.AuthSessionMissingError();
4012
+ const storedAfter = (await (0, helpers_1.getItemAsync)(this.storage, this.storageKey));
4013
+ const storageChangedUnderUs = storedAtStart !== null &&
4014
+ (storedAfter === null || storedAfter.refresh_token !== storedAtStart.refresh_token);
4015
+ if (storageChangedUnderUs) {
4016
+ this._debug(debugName, 'commit guard: storage changed since refresh started, discarding rotated tokens', {
4017
+ // Presence indicators only — never log refresh token fragments,
4018
+ // even partial. Logs may be forwarded to third-party services.
4019
+ startedWith: 'present',
4020
+ nowHolds: storedAfter ? 'replaced' : 'cleared',
4021
+ });
4022
+ const discarded = {
4023
+ data: null,
4024
+ error: new errors_1.AuthRefreshDiscardedError(),
4025
+ };
4026
+ this.refreshingDeferred.resolve(discarded);
4027
+ return discarded;
4028
+ }
4029
+ // Second leg of the commit guard: close the TOCTOU window between the
4030
+ // synchronous `storageChangedUnderUs` check and the actual storage
4031
+ // writes inside `_saveSession`. A concurrent `signOut → _removeSession`
4032
+ // can land inside `_saveSession`'s `await setItemAsync(...)` yields and
4033
+ // clear storage just before we overwrite it. Capture the epoch BEFORE
4034
+ // the save and re-check after; if it advanced, undo the write directly
4035
+ // (do NOT call `_removeSession` — that would emit a duplicate
4036
+ // SIGNED_OUT for the concurrent signOut that already fired one).
4037
+ const epochBeforeSave = this._sessionRemovalEpoch;
3922
4038
  await this._saveSession(data.session);
4039
+ if (this._sessionRemovalEpoch !== epochBeforeSave) {
4040
+ this._debug(debugName, 'commit guard (post-save): _removeSession ran during _saveSession, undoing write');
4041
+ await (0, helpers_1.removeItemAsync)(this.storage, this.storageKey);
4042
+ if (this.userStorage) {
4043
+ await (0, helpers_1.removeItemAsync)(this.userStorage, this.storageKey + '-user');
4044
+ }
4045
+ const discarded = {
4046
+ data: null,
4047
+ error: new errors_1.AuthRefreshDiscardedError(),
4048
+ };
4049
+ this.refreshingDeferred.resolve(discarded);
4050
+ return discarded;
4051
+ }
3923
4052
  await this._notifyAllSubscribers('TOKEN_REFRESHED', data.session);
3924
4053
  const result = { data: data.session, error: null };
3925
4054
  this.refreshingDeferred.resolve(result);
@@ -4013,6 +4142,11 @@ class GoTrueClient {
4013
4142
  }
4014
4143
  }
4015
4144
  async _removeSession() {
4145
+ // Bump synchronously, BEFORE any `await`, so that `_callRefreshToken`'s
4146
+ // post-save check sees the increment whenever this method has started —
4147
+ // even if it hasn't finished. Pairs with the epoch check in
4148
+ // `_callRefreshToken`. See `_sessionRemovalEpoch` field doc.
4149
+ this._sessionRemovalEpoch += 1;
4016
4150
  this._debug('#_removeSession()');
4017
4151
  this.suppressGetSessionWarning = false;
4018
4152
  await (0, helpers_1.removeItemAsync)(this.storage, this.storageKey);
@@ -4183,47 +4317,122 @@ class GoTrueClient {
4183
4317
  this._removeVisibilityChangedCallback();
4184
4318
  await this._stopAutoRefresh();
4185
4319
  }
4320
+ /**
4321
+ * Tears down the client's background work: stops the auto-refresh interval,
4322
+ * removes the `visibilitychange` listener, closes the cross-tab
4323
+ * `BroadcastChannel`, and clears registered `onAuthStateChange` subscribers.
4324
+ *
4325
+ * Call this from cleanup hooks when the client is being replaced before
4326
+ * its JS realm is destroyed. React Strict Mode and HMR are the common
4327
+ * cases. Any in-flight `fetch` calls continue to completion and may still
4328
+ * write to storage; dispose doesn't abort them or erase storage.
4329
+ *
4330
+ * Lifecycle caveat: because in-flight refreshes are not aborted, a
4331
+ * disposed instance can still persist a rotated session to storage after
4332
+ * `dispose()` returns. A subsequent `createClient` against the same
4333
+ * `storageKey` will pick up that session on its next read. If you need
4334
+ * strict isolation between client lifecycles, await any pending auth
4335
+ * operation before calling `dispose()` (or change the `storageKey` for
4336
+ * the replacement client).
4337
+ *
4338
+ * Safe to call repeatedly.
4339
+ *
4340
+ * @category Auth
4341
+ *
4342
+ * @example Cleanup on React unmount
4343
+ * ```ts
4344
+ * useEffect(() => {
4345
+ * const client = createClient(...)
4346
+ * return () => { client.auth.dispose() }
4347
+ * }, [])
4348
+ * ```
4349
+ */
4350
+ async dispose() {
4351
+ var _a;
4352
+ this._removeVisibilityChangedCallback();
4353
+ await this._stopAutoRefresh();
4354
+ (_a = this.broadcastChannel) === null || _a === void 0 ? void 0 : _a.close();
4355
+ this.broadcastChannel = null;
4356
+ this.stateChangeEmitters.clear();
4357
+ }
4186
4358
  /**
4187
4359
  * Runs the auto refresh token tick.
4188
4360
  */
4189
4361
  async _autoRefreshTokenTick() {
4190
4362
  this._debug('#_autoRefreshTokenTick()', 'begin');
4191
- try {
4192
- await this._acquireLock(0, async () => {
4193
- try {
4194
- const now = Date.now();
4363
+ if (this.lock != null) {
4364
+ // TODO(v3): remove legacy lock path. Uses `_acquireLock(0, ...)` which
4365
+ // throws `LockAcquireTimeoutError` immediately if the lock is held —
4366
+ // that's the fail-fast skip path that lets the tick bail out instead
4367
+ // of queuing behind a long-running operation.
4368
+ try {
4369
+ await this._acquireLock(0, async () => {
4195
4370
  try {
4196
- return await this._useSession(async (result) => {
4197
- const { data: { session }, } = result;
4198
- if (!session || !session.refresh_token || !session.expires_at) {
4199
- this._debug('#_autoRefreshTokenTick()', 'no session');
4200
- return;
4201
- }
4202
- // session will expire in this many ticks (or has already expired if <= 0)
4203
- const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / constants_1.AUTO_REFRESH_TICK_DURATION_MS);
4204
- this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${constants_1.AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${constants_1.AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4205
- if (expiresInTicks <= constants_1.AUTO_REFRESH_TICK_THRESHOLD) {
4206
- await this._callRefreshToken(session.refresh_token);
4207
- }
4208
- });
4371
+ const now = Date.now();
4372
+ try {
4373
+ return await this._useSession(async (result) => {
4374
+ const { data: { session }, } = result;
4375
+ if (!session || !session.refresh_token || !session.expires_at) {
4376
+ this._debug('#_autoRefreshTokenTick()', 'no session');
4377
+ return;
4378
+ }
4379
+ const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / constants_1.AUTO_REFRESH_TICK_DURATION_MS);
4380
+ this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${constants_1.AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${constants_1.AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4381
+ if (expiresInTicks <= constants_1.AUTO_REFRESH_TICK_THRESHOLD) {
4382
+ await this._callRefreshToken(session.refresh_token);
4383
+ }
4384
+ });
4385
+ }
4386
+ catch (e) {
4387
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4388
+ }
4209
4389
  }
4210
- catch (e) {
4211
- console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4390
+ finally {
4391
+ this._debug('#_autoRefreshTokenTick()', 'end');
4212
4392
  }
4393
+ });
4394
+ }
4395
+ catch (e) {
4396
+ if (e instanceof locks_1.LockAcquireTimeoutError) {
4397
+ this._debug('auto refresh token tick lock not available');
4213
4398
  }
4214
- finally {
4215
- this._debug('#_autoRefreshTokenTick()', 'end');
4399
+ else {
4400
+ throw e;
4216
4401
  }
4217
- });
4402
+ }
4403
+ return;
4218
4404
  }
4219
- catch (e) {
4220
- if (e instanceof locks_1.LockAcquireTimeoutError) {
4221
- this._debug('auto refresh token tick lock not available');
4405
+ // Lockless default: skip if a refresh is already in flight.
4406
+ // `_callRefreshToken` also dedupes via the same field; this is just a
4407
+ // fast-path skip to avoid an unnecessary storage read.
4408
+ if (this.refreshingDeferred !== null) {
4409
+ this._debug('#_autoRefreshTokenTick()', 'refresh already in flight, skipping');
4410
+ return;
4411
+ }
4412
+ try {
4413
+ const now = Date.now();
4414
+ try {
4415
+ await this._useSession(async (result) => {
4416
+ const { data: { session }, } = result;
4417
+ if (!session || !session.refresh_token || !session.expires_at) {
4418
+ this._debug('#_autoRefreshTokenTick()', 'no session');
4419
+ return;
4420
+ }
4421
+ // session will expire in this many ticks (or has already expired if <= 0)
4422
+ const expiresInTicks = Math.floor((session.expires_at * 1000 - now) / constants_1.AUTO_REFRESH_TICK_DURATION_MS);
4423
+ this._debug('#_autoRefreshTokenTick()', `access token expires in ${expiresInTicks} ticks, a tick lasts ${constants_1.AUTO_REFRESH_TICK_DURATION_MS}ms, refresh threshold is ${constants_1.AUTO_REFRESH_TICK_THRESHOLD} ticks`);
4424
+ if (expiresInTicks <= constants_1.AUTO_REFRESH_TICK_THRESHOLD) {
4425
+ await this._callRefreshToken(session.refresh_token);
4426
+ }
4427
+ });
4222
4428
  }
4223
- else {
4224
- throw e;
4429
+ catch (e) {
4430
+ console.error('Auto refresh tick failed with error. This is likely a transient error.', e);
4225
4431
  }
4226
4432
  }
4433
+ finally {
4434
+ this._debug('#_autoRefreshTokenTick()', 'end');
4435
+ }
4227
4436
  }
4228
4437
  /**
4229
4438
  * Registers callbacks on the browser / platform, which in-turn run
@@ -4272,18 +4481,26 @@ class GoTrueClient {
4272
4481
  if (!calledFromInitialize) {
4273
4482
  // called when the visibility has changed, i.e. the browser
4274
4483
  // transitioned from hidden -> visible so we need to see if the session
4275
- // should be recovered immediately... but to do that we need to acquire
4276
- // the lock first asynchronously
4484
+ // should be recovered
4277
4485
  await this.initializePromise;
4278
- await this._acquireLock(this.lockAcquireTimeout, async () => {
4486
+ if (this.lock != null) {
4487
+ // TODO(v3): remove legacy lock path
4488
+ await this._acquireLock(this.lockAcquireTimeout, async () => {
4489
+ if (document.visibilityState !== 'visible') {
4490
+ this._debug(methodName, 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting');
4491
+ return;
4492
+ }
4493
+ await this._recoverAndRefresh();
4494
+ });
4495
+ }
4496
+ else {
4279
4497
  if (document.visibilityState !== 'visible') {
4280
- this._debug(methodName, 'acquired the lock to recover the session, but the browser visibilityState is no longer visible, aborting');
4281
- // visibility has changed while waiting for the lock, abort
4498
+ this._debug(methodName, 'visibilityState is no longer visible, skipping recovery');
4282
4499
  return;
4283
4500
  }
4284
4501
  // recover the session
4285
4502
  await this._recoverAndRefresh();
4286
- });
4503
+ }
4287
4504
  }
4288
4505
  }
4289
4506
  else if (document.visibilityState === 'hidden') {
@@ -4379,7 +4596,7 @@ class GoTrueClient {
4379
4596
  }
4380
4597
  }
4381
4598
  async _verify(params) {
4382
- return this._acquireLock(this.lockAcquireTimeout, async () => {
4599
+ const run = async () => {
4383
4600
  try {
4384
4601
  return await this._useSession(async (result) => {
4385
4602
  var _a;
@@ -4413,10 +4630,15 @@ class GoTrueClient {
4413
4630
  }
4414
4631
  throw error;
4415
4632
  }
4416
- });
4633
+ };
4634
+ if (this.lock != null) {
4635
+ // TODO(v3): remove legacy lock path
4636
+ return this._acquireLock(this.lockAcquireTimeout, run);
4637
+ }
4638
+ return run();
4417
4639
  }
4418
4640
  async _challenge(params) {
4419
- return this._acquireLock(this.lockAcquireTimeout, async () => {
4641
+ const run = async () => {
4420
4642
  try {
4421
4643
  return await this._useSession(async (result) => {
4422
4644
  var _a;
@@ -4456,14 +4678,17 @@ class GoTrueClient {
4456
4678
  }
4457
4679
  throw error;
4458
4680
  }
4459
- });
4681
+ };
4682
+ if (this.lock != null) {
4683
+ // TODO(v3): remove legacy lock path
4684
+ return this._acquireLock(this.lockAcquireTimeout, run);
4685
+ }
4686
+ return run();
4460
4687
  }
4461
4688
  /**
4462
4689
  * {@see GoTrueMFAApi#challengeAndVerify}
4463
4690
  */
4464
4691
  async _challengeAndVerify(params) {
4465
- // both _challenge and _verify independently acquire the lock, so no need
4466
- // to acquire it here
4467
4692
  const { data: challengeData, error: challengeError } = await this._challenge({
4468
4693
  factorId: params.factorId,
4469
4694
  });
@@ -4481,7 +4706,6 @@ class GoTrueClient {
4481
4706
  */
4482
4707
  async _listFactors() {
4483
4708
  var _a;
4484
- // use #getUser instead of #_getUser as the former acquires a lock
4485
4709
  const { data: { user }, error: userError, } = await this.getUser();
4486
4710
  if (userError) {
4487
4711
  return { data: null, error: userError };