diff --git a/.changeset/fresh-tigers-hunt.md b/.changeset/fresh-tigers-hunt.md new file mode 100644 index 00000000000..0013de7acf6 --- /dev/null +++ b/.changeset/fresh-tigers-hunt.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': major +--- + +Add proactive session token refresh. Tokens are now automatically refreshed in the background before they expire, reducing latency for API calls near token expiration. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4f78f5c0554..3bd20ec6fee 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -3,7 +3,7 @@ { "path": "./dist/clerk.js", "maxSize": "538KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, - { "path": "./dist/clerk.legacy.browser.js", "maxSize": "105KB" }, + { "path": "./dist/clerk.legacy.browser.js", "maxSize": "106KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, { "path": "./dist/clerk.native.js", "maxSize": "65KB" }, { "path": "./dist/vendors*.js", "maxSize": "7KB" }, diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..14174c218af 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -206,9 +206,9 @@ describe('SessionTokenCache', () => { } as MessageEvent; broadcastListener(newerEvent); - const cachedEntryAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterNewer).toBeDefined(); - const newerCreatedAt = cachedEntryAfterNewer?.createdAt; + const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterNewer).toBeDefined(); + const newerCreatedAt = resultAfterNewer?.entry.createdAt; // mockJwt has iat: 1666648250, so create an older one with iat: 1666648190 (60 seconds earlier) const olderJwt = @@ -226,9 +226,9 @@ describe('SessionTokenCache', () => { broadcastListener(olderEvent); - const cachedEntryAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntryAfterOlder).toBeDefined(); - expect(cachedEntryAfterOlder?.createdAt).toBe(newerCreatedAt); + const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(resultAfterOlder).toBeDefined(); + expect(resultAfterOlder?.entry.createdAt).toBe(newerCreatedAt); }); it('successfully updates cache with valid token', () => { @@ -245,9 +245,9 @@ describe('SessionTokenCache', () => { broadcastListener(event); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('session_123'); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('session_123'); }); it('does not re-broadcast when receiving a broadcast message', async () => { @@ -271,8 +271,8 @@ describe('SessionTokenCache', () => { await Promise.resolve(); // Verify cache was updated - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_123' }); - expect(cachedEntry).toBeDefined(); + const result = SessionTokenCache.get({ tokenId: 'session_123' }); + expect(result).toBeDefined(); // Critical: postMessage should NOT be called when handling a broadcast expect(mockBroadcastChannel.postMessage).not.toHaveBeenCalled(); @@ -331,9 +331,9 @@ describe('SessionTokenCache', () => { // Wait for promise to resolve await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'future_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('future_token'); + const result = SessionTokenCache.get({ tokenId: 'future_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('future_token'); }); it('removes token when it has already expired based on duration', async () => { @@ -351,11 +351,11 @@ describe('SessionTokenCache', () => { await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'expired_token' }); - expect(cachedEntry).toBeUndefined(); + const result = SessionTokenCache.get({ tokenId: 'expired_token' }); + expect(result).toBeUndefined(); }); - it('removes token when it expires within the leeway threshold', async () => { + it('returns token when remaining TTL is above poller interval', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const iat = nowSeconds; const exp = iat + 20; @@ -366,12 +366,15 @@ describe('SessionTokenCache', () => { jwt: { claims: { exp, iat } }, } as any); - SessionTokenCache.set({ createdAt: nowSeconds - 13, tokenId: 'soon_expired_token', tokenResolver }); + // Token has 20s TTL, created 11s ago = 9s remaining (> 5s poller interval) + SessionTokenCache.set({ createdAt: nowSeconds - 11, tokenId: 'soon_expired_token', tokenResolver }); await tokenResolver; - const cachedEntry = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); - expect(cachedEntry).toBeUndefined(); + // Token is still valid (9s > 5s poller interval), so it should be returned + const result = SessionTokenCache.get({ tokenId: 'soon_expired_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('soon_expired_token'); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -380,9 +383,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: 'pending_token', tokenResolver: pendingTokenResolver }); - const cachedEntry = SessionTokenCache.get({ tokenId: 'pending_token' }); - expect(cachedEntry).toBeDefined(); - expect(cachedEntry?.tokenId).toBe('pending_token'); + const result = SessionTokenCache.get({ tokenId: 'pending_token' }); + expect(result).toBeDefined(); + expect(result?.entry.tokenId).toBe('pending_token'); }); }); @@ -471,7 +474,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); expect(SessionTokenCache.size()).toBe(1); SessionTokenCache.clear(); @@ -512,78 +515,217 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); - const cachedWhilePending = SessionTokenCache.get(key); - expect(cachedWhilePending).toBeDefined(); - expect(cachedWhilePending?.tokenId).toBe('lifecycle-token'); + const resultWhilePending = SessionTokenCache.get(key); + expect(resultWhilePending).toBeDefined(); + expect(resultWhilePending?.entry.tokenId).toBe('lifecycle-token'); expect(isResolved).toBe(false); vi.advanceTimersByTime(100); await tokenResolver; - const cachedAfterResolved = SessionTokenCache.get(key); + const resultAfterResolved = SessionTokenCache.get(key); expect(isResolved).toBe(true); - expect(cachedAfterResolved).toBeDefined(); - expect(cachedAfterResolved?.tokenId).toBe('lifecycle-token'); + expect(resultAfterResolved).toBeDefined(); + expect(resultAfterResolved?.entry.tokenId).toBe('lifecycle-token'); vi.advanceTimersByTime(60 * 1000); - const cachedAfterExpiration = SessionTokenCache.get(key); - expect(cachedAfterExpiration).toBeUndefined(); + const resultAfterExpiration = SessionTokenCache.get(key); + expect(resultAfterExpiration).toBeUndefined(); }); }); - describe('leeway precision', () => { - it('includes 5 second sync leeway on top of default 10 second leeway', async () => { + describe('proactive refresh timer', () => { + it('calls onRefresh callback when refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'leeway-token', + id: 'refresh-timer-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'leeway-test', tokenId: 'leeway-token' }; + const key = { audience: 'refresh-test', tokenId: 'refresh-timer-token' }; + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Timer should fire at: 60s - 15s (leeway) - 2s (lead time) = 43s + expect(onRefresh).not.toHaveBeenCalled(); + + // Advance to just before timer should fire + vi.advanceTimersByTime(42 * 1000); + expect(onRefresh).not.toHaveBeenCalled(); + + // Advance past timer fire time + vi.advanceTimersByTime(2 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not schedule refresh timer when onRefresh is not provided', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'no-callback-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'no-callback-token' }; + + // Set without onRefresh (like broadcast-received tokens) SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toMatchObject({ tokenId: 'leeway-token' }); + // Token should still be cached and retrievable + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('no-callback-token'); - vi.advanceTimersByTime(44 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + // Advance past when timer would fire - nothing should happen + vi.advanceTimersByTime(50 * 1000); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + // Token should still be valid (10s remaining) + const stillCached = SessionTokenCache.get(key); + expect(stillCached?.entry.tokenId).toBe('no-callback-token'); + }); - vi.advanceTimersByTime(1 * 1000); - expect(SessionTokenCache.get(key)).toBeUndefined(); + it('clears refresh timer when entry is deleted', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'clear-timer-token', + jwt, + object: 'token', + }); + + const onRefresh = vi.fn(); + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'clear-timer-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Clear the cache before timer fires + vi.advanceTimersByTime(30 * 1000); + SessionTokenCache.clear(); + + // Advance past when timer would have fired + vi.advanceTimersByTime(20 * 1000); + + // onRefresh should not have been called since entry was cleared + expect(onRefresh).not.toHaveBeenCalled(); + }); + + it('does not schedule refresh timer for tokens with very short TTL', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + // Token with 10s TTL - refreshFireTime would be 10 - 15 - 2 = -7 (negative) + const jwt = createJwtWithTtl(nowSeconds, 10); + + const token = new Token({ + id: 'short-ttl-token', + jwt, + object: 'token', + }); + + const onRefresh = vi.fn(); + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'short-ttl-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Advance past token expiration + vi.advanceTimersByTime(15 * 1000); + + // onRefresh should not have been called - no timer was scheduled + expect(onRefresh).not.toHaveBeenCalled(); }); - it('enforces minimum 5 second sync leeway even when leeway is set to 0', async () => { + it('returns token until expiration even after refresh timer fires', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); const token = new Token({ - id: 'zero-leeway-token', + id: 'still-valid-token', jwt, object: 'token', }); + const onRefresh = vi.fn(); const tokenResolver = Promise.resolve(token); - const key = { audience: 'zero-leeway-test', tokenId: 'zero-leeway-token' }; + const key = { tokenId: 'still-valid-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onRefresh }); + await tokenResolver; + + // Advance past refresh timer (43s) + vi.advanceTimersByTime(50 * 1000); + expect(onRefresh).toHaveBeenCalledTimes(1); + + // Token should still be retrievable (10s remaining, > 5s poller interval) + const result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('still-valid-token'); + }); + }); + + describe('hard cutoff behavior', () => { + it('returns token when TTL is above poller interval', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'above-cutoff-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'above-cutoff-token' }; SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key, 0)).toMatchObject({ tokenId: 'zero-leeway-token' }); + // 60s remaining + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); + + // 10s remaining (above 5s cutoff) + vi.advanceTimersByTime(50 * 1000); + result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('above-cutoff-token'); + }); + + it('forces synchronous refresh when token has less than poller interval remaining', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'hard-cutoff-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'hard-cutoff-token' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + await tokenResolver; + // 6s remaining (just above 5s cutoff) vi.advanceTimersByTime(54 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeDefined(); + let result = SessionTokenCache.get(key); + expect(result?.entry.tokenId).toBe('hard-cutoff-token'); + // 4s remaining (below 5s cutoff) - forces sync refresh vi.advanceTimersByTime(2 * 1000); - expect(SessionTokenCache.get(key, 0)).toBeUndefined(); + result = SessionTokenCache.get(key); + expect(result).toBeUndefined(); }); }); @@ -604,7 +746,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); @@ -627,10 +769,10 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(90 * 1000); - expect(SessionTokenCache.get(key)).toBeDefined(); + expect(SessionTokenCache.get(key)?.entry).toBeDefined(); vi.advanceTimersByTime(30 * 1000); expect(SessionTokenCache.get(key)).toBeUndefined(); @@ -656,7 +798,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: label, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get({ tokenId: label })).toBeDefined(); + expect(SessionTokenCache.get({ tokenId: label })?.entry).toBeDefined(); vi.advanceTimersByTime(ttl * 1000); expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined(); @@ -684,9 +826,9 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...keyWithAudience, tokenResolver }); await tokenResolver; - const cached = SessionTokenCache.get(keyWithAudience); - expect(cached).toBeDefined(); - expect(cached?.audience).toBe('https://api.example.com'); + const result = SessionTokenCache.get(keyWithAudience); + expect(result).toBeDefined(); + expect(result?.entry.audience).toBe('https://api.example.com'); }); it('treats tokens with different audiences as separate entries', async () => { @@ -709,8 +851,8 @@ describe('SessionTokenCache', () => { await Promise.all([resolver1, resolver2]); expect(SessionTokenCache.size()).toBe(2); - expect(SessionTokenCache.get(key1)).toBeDefined(); - expect(SessionTokenCache.get(key2)).toBeDefined(); + expect(SessionTokenCache.get(key1)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key2)?.entry).toBeDefined(); }); }); @@ -762,6 +904,61 @@ describe('SessionTokenCache', () => { }); }); + describe('resolvedToken', () => { + it('is populated after tokenResolver resolves', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'resolved-token-test', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const key = { tokenId: 'resolved-token-test' }; + + SessionTokenCache.set({ ...key, tokenResolver }); + + // Before promise resolves, resolvedToken should be undefined + let result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeUndefined(); + + // Wait for promise to resolve + await tokenResolver; + + // After promise resolves, resolvedToken should be populated + result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken?.getRawString()).toBeTruthy(); + }); + + it('can be provided when setting a pre-resolved token', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'pre-resolved-token', + jwt, + object: 'token', + }); + + const key = { tokenId: 'pre-resolved-token' }; + + // Set with both tokenResolver and resolvedToken + SessionTokenCache.set({ + ...key, + resolvedToken: token, + tokenResolver: Promise.resolve(token), + }); + + // resolvedToken should be immediately available + const result = SessionTokenCache.get(key); + expect(result?.entry.resolvedToken).toBeDefined(); + expect(result?.entry.resolvedToken).toBe(token); + }); + }); + describe('multi-session isolation', () => { it('stores tokens from different session IDs separately without interference', async () => { const nowSeconds = Math.floor(Date.now() / 1000); @@ -813,15 +1010,15 @@ describe('SessionTokenCache', () => { // (not session2's token) - tokens are isolated by tokenId const retrievedSession1Token = SessionTokenCache.get({ tokenId: session1Id }); expect(retrievedSession1Token).toBeDefined(); - const resolvedSession1Token = await retrievedSession1Token!.tokenResolver; + const resolvedSession1Token = await retrievedSession1Token!.entry.tokenResolver; expect(resolvedSession1Token.jwt?.claims?.iat).toBe(nowSeconds); - expect(retrievedSession1Token!.tokenId).toBe(session1Id); + expect(retrievedSession1Token!.entry.tokenId).toBe(session1Id); // Verify session2's token is separate const retrievedSession2Token = SessionTokenCache.get({ tokenId: session2Id }); expect(retrievedSession2Token).toBeDefined(); - expect(retrievedSession2Token!.tokenId).toBe(session2Id); - expect(retrievedSession2Token!.tokenId).not.toBe(session1Id); + expect(retrievedSession2Token!.entry.tokenId).toBe(session2Id); + expect(retrievedSession2Token!.entry.tokenId).not.toBe(session1Id); }); it('accepts broadcast messages from the same session ID', async () => { @@ -847,7 +1044,7 @@ describe('SessionTokenCache', () => { const cachedToken = SessionTokenCache.get({ tokenId: sessionId }); expect(cachedToken).toBeDefined(); - const resolvedToken = await cachedToken!.tokenResolver; + const resolvedToken = await cachedToken!.entry.tokenResolver; expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds - 10); const newerJwt = createJwtWithTtl(nowSeconds, 60); @@ -867,7 +1064,7 @@ describe('SessionTokenCache', () => { await vi.waitFor(async () => { const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); expect(updatedCached).toBeDefined(); - const updatedToken = await updatedCached!.tokenResolver; + const updatedToken = await updatedCached!.entry.tokenResolver; expect(updatedToken.jwt?.claims?.iat).toBe(nowSeconds); }); diff --git a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts index 91e8040f79d..ed9f1f04c76 100644 --- a/packages/clerk-js/src/core/auth/SessionCookiePoller.ts +++ b/packages/clerk-js/src/core/auth/SessionCookiePoller.ts @@ -3,7 +3,8 @@ import { createWorkerTimers } from '@clerk/shared/workerTimers'; import { SafeLock } from './safeLock'; const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; -const INTERVAL_IN_MS = 5 * 1_000; + +export const POLLER_INTERVAL_IN_MS = 5 * 1_000; export class SessionCookiePoller { private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); @@ -20,7 +21,7 @@ export class SessionCookiePoller { const run = async () => { this.initiated = true; await this.lock.acquireLockAndRun(cb); - this.timerId = this.workerTimers.setTimeout(run, INTERVAL_IN_MS); + this.timerId = this.workerTimers.setTimeout(run, POLLER_INTERVAL_IN_MS); }; void run(); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..842230da8f5 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -44,6 +44,12 @@ import { SessionVerification } from './SessionVerification'; export class Session extends BaseResource implements SessionResource { pathRoot = '/client/sessions'; + /** + * Tracks token IDs with in-flight background refresh requests. + * Prevents multiple concurrent background refreshes for the same token. + */ + static #backgroundRefreshInProgress = new Set(); + id!: string; status!: SessionStatus; lastActiveAt!: Date; @@ -132,9 +138,14 @@ export class Session extends BaseResource implements SessionResource { #hydrateCache = (token: TokenResource | null) => { if (token) { + const tokenId = this.#getCacheId(); + // Dispatch tokenUpdate for __session tokens with the session's active organization ID + const shouldDispatchTokenUpdate = true; SessionTokenCache.set({ - tokenId: this.#getCacheId(), + tokenId, tokenResolver: Promise.resolve(token), + onRefresh: () => + this.#refreshTokenInBackground(undefined, this.lastActiveOrganizationId, tokenId, shouldDispatchTokenUpdate), }); } }; @@ -350,81 +361,132 @@ export class Session extends BaseResource implements SessionResource { return null; } - const { leewayInSeconds, template, skipCache = false } = options || {}; + const { skipCache = false, template } = options || {}; // If no organization ID is provided, default to the selected organization in memory // Note: this explicitly allows passing `null` or `""`, which should select the personal workspace. const organizationId = typeof options?.organizationId === 'undefined' ? this.lastActiveOrganizationId : options?.organizationId; - if (!template && Number(leewayInSeconds) >= 60) { - throw new Error('Leeway can not exceed the token lifespan (60 seconds)'); - } - const tokenId = this.#getCacheId(template, organizationId); - const cachedEntry = skipCache ? undefined : SessionTokenCache.get({ tokenId }, leewayInSeconds); + const cacheResult = skipCache ? undefined : SessionTokenCache.get({ tokenId }); // Dispatch tokenUpdate only for __session tokens with the session's active organization ID, and not JWT templates const shouldDispatchTokenUpdate = !template && organizationId === this.lastActiveOrganizationId; - if (cachedEntry) { - debugLogger.debug( - 'Using cached token (no fetch needed)', - { - tokenId, - }, - 'session', - ); - const cachedToken = await cachedEntry.tokenResolver; + if (cacheResult) { + // Proactive refresh is handled by timers scheduled in the cache + // Prefer synchronous read to avoid microtask overhead when token is already resolved + const cachedToken = cacheResult.entry.resolvedToken ?? (await cacheResult.entry.tokenResolver); if (shouldDispatchTokenUpdate) { eventBus.emit(events.TokenUpdate, { token: cachedToken }); } - // Return null when raw string is empty to indicate that there it's signed-out + // Return null when raw string is empty to indicate signed-out state return cachedToken.getRawString() || null; } - debugLogger.info( - 'Fetching new token from API', - { - organizationId, - template, - tokenId, - }, - 'session', - ); + return this.#fetchToken(template, organizationId, tokenId, shouldDispatchTokenUpdate, skipCache); + } + #createTokenResolver( + template: string | undefined, + organizationId: string | undefined | null, + skipCache: boolean, + ): Promise { const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; - // TODO: update template endpoint to accept organizationId - const params: Record = template ? {} : { organizationId }; - + const params: Record = template ? {} : { organizationId: organizationId ?? null }; const lastActiveToken = this.lastActiveToken?.getRawString(); - const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + return Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { if (MissingExpiredTokenError.is(e) && lastActiveToken) { return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } throw e; }); - SessionTokenCache.set({ tokenId, tokenResolver }); + } - return tokenResolver.then(token => { - if (shouldDispatchTokenUpdate) { - eventBus.emit(events.TokenUpdate, { token }); + #dispatchTokenEvents(token: TokenResource, shouldDispatch: boolean): void { + if (!shouldDispatch) { + return; + } - if (token.jwt) { - this.lastActiveToken = token; - // Emits the updated session with the new token to the state listeners - eventBus.emit(events.SessionTokenResolved, null); - } - } + eventBus.emit(events.TokenUpdate, { token }); - // Return null when raw string is empty to indicate that there it's signed-out + if (token.jwt) { + this.lastActiveToken = token; + eventBus.emit(events.SessionTokenResolved, null); + } + } + + #fetchToken( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + skipCache: boolean, + ): Promise { + debugLogger.info('Fetching new token from API', { organizationId, template, tokenId }, 'session'); + + const tokenResolver = this.#createTokenResolver(template, organizationId, skipCache); + SessionTokenCache.set({ + tokenId, + tokenResolver, + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + }); + + return tokenResolver.then(token => { + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + // Return null when raw string is empty to indicate signed-out state return token.getRawString() || null; }); } + /** + * Triggers a background token refresh without caching the pending promise. + * This allows concurrent getToken() calls to continue returning the stale cached token + * while the refresh is in progress. The cache is only updated after the refresh succeeds. + * + * Uses a static Set to prevent multiple concurrent background refreshes for the same token. + */ + #refreshTokenInBackground( + template: string | undefined, + organizationId: string | undefined | null, + tokenId: string, + shouldDispatchTokenUpdate: boolean, + ): void { + // Prevent multiple concurrent background refreshes for the same token + if (Session.#backgroundRefreshInProgress.has(tokenId)) { + return; + } + + Session.#backgroundRefreshInProgress.add(tokenId); + + const tokenResolver = this.#createTokenResolver(template, organizationId, false); + + // Don't cache the promise immediately - only update cache on success + // This allows concurrent calls to continue using the stale token + tokenResolver + .then(token => { + // Cache the resolved token for future calls + // Re-register onRefresh to handle the next refresh cycle when this token approaches expiration + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(token), + onRefresh: () => this.#refreshTokenInBackground(template, organizationId, tokenId, shouldDispatchTokenUpdate), + }); + this.#dispatchTokenEvents(token, shouldDispatchTokenUpdate); + }) + .catch(error => { + // Log but don't propagate - callers already have stale token + debugLogger.warn('Background token refresh failed', { error, tokenId }, 'session'); + }) + .finally(() => { + Session.#backgroundRefreshInProgress.delete(tokenId); + }); + } + get currentTask() { const [task] = this.tasks ?? []; return task; diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index eb51257191d..28f904abe53 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -21,9 +21,9 @@ describe('Session', () => { beforeEach(() => { // Mock Date.now() to make the test tokens appear valid // mockJwt has iat: 1666648250, exp: 1666648310 - // Set current time to 1666648260 (10 seconds after iat, 50 seconds before exp) + // Set current time to iat so token appears freshly issued (60 seconds before exp) vi.useFakeTimers(); - vi.setSystemTime(new Date(1666648260 * 1000)); + vi.setSystemTime(new Date(1666648250 * 1000)); }); afterEach(() => { @@ -101,7 +101,7 @@ describe('Session', () => { expect(dispatchSpy).toHaveBeenCalledTimes(2); }); - it('does not re-cache token when Session is reconstructed with same token', async () => { + it('returns same token without API call when Session is reconstructed', async () => { BaseResource.clerk = clerkMock({ organization: new Organization({ id: 'activeOrganization' } as OrganizationJSON), }); @@ -120,10 +120,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry1 = SessionTokenCache.get({ tokenId: 'session_1-activeOrganization' }); - expect(cachedEntry1).toBeDefined(); - const session2 = new Session({ status: 'active', id: 'session_1', @@ -136,8 +132,6 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const token1 = await session1.getToken(); const token2 = await session2.getToken(); @@ -146,12 +140,12 @@ describe('Session', () => { expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); - it('caches token from cookie during degraded mode recovery', async () => { + it('returns lastActiveToken without API call (degraded mode recovery)', async () => { BaseResource.clerk = clerkMock(); SessionTokenCache.clear(); - const sessionFromCookie = new Session({ + const session = new Session({ status: 'active', id: 'session_1', object: 'session', @@ -163,11 +157,8 @@ describe('Session', () => { updated_at: new Date().getTime(), } as SessionJSON); - expect(SessionTokenCache.size()).toBe(1); - const cachedEntry = SessionTokenCache.get({ tokenId: 'session_1' }); - expect(cachedEntry).toBeDefined(); + const token = await session.getToken(); - const token = await sessionFromCookie.getToken(); expect(token).toEqual(mockJwt); expect(BaseResource.clerk.getFapiClient().request).not.toHaveBeenCalled(); }); @@ -427,6 +418,182 @@ describe('Session', () => { expect(requestSpy).toHaveBeenCalledTimes(2); }); + + describe('timer-based proactive refresh', () => { + it('triggers background refresh via timer before leeway period', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + // Create session with last_active_token to trigger cache hydration and timer scheduling + new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + requestSpy.mockClear(); + + // Timer fires at 60s - 15s (leeway) - 2s (lead time) = 43s + // Advance to just before timer fires + vi.advanceTimersByTime(42 * 1000); + expect(requestSpy).not.toHaveBeenCalled(); + + // Set up the mock for the refresh + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + + // Advance past timer fire time + await vi.advanceTimersByTimeAsync(2 * 1000); + + // Background refresh should have been triggered by the timer + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + it('continues returning cached token while timer-triggered refresh is pending', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + requestSpy.mockClear(); + + // Hold the network request pending + let resolveNetworkRequest!: (value: any) => void; + requestSpy.mockReturnValueOnce( + new Promise(resolve => { + resolveNetworkRequest = resolve; + }), + ); + + // Advance to trigger the timer (43s) + await vi.advanceTimersByTimeAsync(44 * 1000); + + // Concurrent calls should all return cached token + const [token1, token2, token3] = await Promise.all([ + session.getToken(), + session.getToken(), + session.getToken(), + ]); + + expect(token1).toEqual(mockJwt); + expect(token2).toEqual(mockJwt); + expect(token3).toEqual(mockJwt); + + // Cleanup: resolve the pending request + resolveNetworkRequest({ payload: { object: 'token', jwt: mockJwt }, status: 200 }); + await vi.advanceTimersByTimeAsync(0); + }); + + it('continues returning tokens after timer-triggered refresh failure', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + requestSpy.mockClear(); + + // Timer-triggered refresh fails + requestSpy.mockRejectedValueOnce(new Error('Network error')); + + // Advance to trigger timer (43s) and wait for it to complete + await vi.advanceTimersByTimeAsync(44 * 1000); + + // getToken should still return the cached token + const token = await session.getToken(); + expect(token).toEqual(mockJwt); + }); + + it('uses refreshed token after timer-triggered refresh succeeds', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const newMockJwt = + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjY2NDg0MDAsImlhdCI6MTY2NjY0ODM0MCwiaXNzIjoiaHR0cHM6Ly9jbGVyay5leGFtcGxlLmNvbSIsImp0aSI6Im5ld3Rva2VuIiwibmJmIjoxNjY2NjQ4MzQwLCJzaWQiOiJzZXNzXzFxcTlveTVHaU5IeGRSMlhXVTZnRzZtSWNCWCIsInN1YiI6InVzZXJfMXFxOW95NUdpTkh4ZFIyWFdVNmdHNm1JY0JYIn0.mock'; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + requestSpy.mockClear(); + + // Timer-triggered refresh returns new token + requestSpy.mockResolvedValueOnce({ payload: { object: 'token', jwt: newMockJwt }, status: 200 }); + + // Advance to trigger timer and wait for refresh to complete + await vi.advanceTimersByTimeAsync(44 * 1000); + + // Subsequent call returns refreshed token (no new API call needed) + requestSpy.mockClear(); + const freshToken = await session.getToken(); + expect(freshToken).toEqual(newMockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + + it('does not make API call when token has plenty of time remaining', async () => { + BaseResource.clerk = clerkMock(); + const requestSpy = BaseResource.clerk.getFapiClient().request as Mock; + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + await Promise.resolve(); + + // With 40s remaining and default 15s threshold, token is fresh + vi.advanceTimersByTime(20 * 1000); // 40s remaining + + requestSpy.mockClear(); + const token = await session.getToken(); + + expect(token).toEqual(mockJwt); + expect(requestSpy).not.toHaveBeenCalled(); + }); + }); }); describe('touch()', () => { diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..98bfaa25fae 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -3,6 +3,7 @@ import type { TokenResource } from '@clerk/shared/types'; import { debugLogger } from '@/utils/debug'; import { TokenId } from '@/utils/tokenId'; +import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller'; import { Token } from './resources/internal'; /** @@ -23,6 +24,17 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * Callback to refresh this token before it expires. + * Called by the proactive refresh timer to trigger background refresh. + * If not provided, no refresh timer will be scheduled (e.g., for broadcast-received tokens). + */ + onRefresh?: () => void; + /** + * The resolved token value for synchronous reads. + * Populated after tokenResolver resolves. Check this first to avoid microtask overhead. + */ + resolvedToken?: TokenResource; /** * Promise that resolves to the TokenResource. * May be pending and should be awaited before accessing token data. @@ -33,13 +45,23 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { type Seconds = number; /** - * Internal cache value containing the entry, expiration metadata, and cleanup timer. + * Internal cache value containing the entry, expiration metadata, and timers. */ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; + /** Timer for proactive refresh before token enters leeway period */ + refreshTimeoutId?: ReturnType; +} + +/** + * Result from cache lookup containing the entry. + */ +export interface TokenCacheGetResult { + entry: TokenCacheEntry; } export interface TokenCache { @@ -56,13 +78,14 @@ export interface TokenCache { close(): void; /** - * Retrieves a cached token entry if it exists and has not expired. + * Retrieves a cached token entry if it exists and is safe to use. + * Forces synchronous refresh if token has less than one poller interval remaining. + * Proactive refresh is handled by timers scheduled when tokens are cached. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @param leeway - Optional seconds before expiration to treat token as expired (default: 10s). Combined with 5s sync leeway. - * @returns The cached TokenCacheEntry if found and valid, undefined otherwise + * @returns Result with entry, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. @@ -82,9 +105,17 @@ export interface TokenCache { const KEY_PREFIX = 'clerk'; const DELIMITER = '::'; -const LEEWAY = 10; -// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller -const SYNC_LEEWAY = 5; + +/** + * Default seconds before token expiration to trigger background refresh. + * This threshold accounts for timer jitter, SafeLock contention (~5s), network latency, + * and tolerance for missed poller ticks. + * + * Users can customize this value: + * - Lower values (min: 5s) delay background refresh until closer to expiration + * - Higher values trigger earlier background refresh but may cause more frequent requests + */ +const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -170,11 +201,14 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } }); cache.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON, leeway = LEEWAY): TokenCacheEntry | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { ensureBroadcastChannel(); const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); @@ -186,21 +220,23 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const nowSeconds = Math.floor(Date.now() / 1000); const elapsed = nowSeconds - value.createdAt; + const remainingTtl = (value.expiresIn ?? Infinity) - elapsed; - // Include poller interval as part of the leeway to ensure the cache value - // will be valid for more than the SYNC_LEEWAY or the leeway in the next poll. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const expiresSoon = value.expiresIn! - elapsed < (leeway || 1) + SYNC_LEEWAY; - - if (expiresSoon) { + // Token expired or dangerously close to expiration - force synchronous refresh + // Uses poller interval as threshold since the poller might not get to it in time + if (remainingTtl <= POLLER_INTERVAL_IN_MS / 1000) { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } cache.delete(cacheKey.toKey()); return; } - return value.entry; + // Proactive refresh is handled by timers scheduled in setInternal() + return { entry: value.entry }; }; /** @@ -249,9 +285,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } try { - const existingEntry = get({ tokenId: data.tokenId }); - if (existingEntry) { - const existingToken = await existingEntry.tokenResolver; + const result = get({ tokenId: data.tokenId }); + if (result) { + const existingToken = await result.entry.tokenResolver; const existingIat = existingToken.jwt?.claims?.iat; if (existingIat && existingIat >= iat) { debugLogger.debug( @@ -324,12 +360,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (cachedValue.timeoutId !== undefined) { clearTimeout(cachedValue.timeoutId); } + if (cachedValue.refreshTimeoutId !== undefined) { + clearTimeout(cachedValue.refreshTimeoutId); + } cache.delete(key); } }; entry.tokenResolver .then(newToken => { + // Store resolved token for synchronous reads + entry.resolvedToken = newToken; + const claims = newToken.jwt?.claims; if (!claims || typeof claims.exp !== 'number' || typeof claims.iat !== 'number') { return deleteKey(); @@ -339,6 +381,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const issuedAt = claims.iat; const expiresIn: Seconds = expiresAt - issuedAt; + value.createdAt = issuedAt; value.expiresIn = expiresIn; const timeoutId = setTimeout(deleteKey, expiresIn * 1000); @@ -350,6 +393,27 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { (timeoutId as any).unref(); } + // Schedule proactive refresh timer to fire before token enters leeway period + // This ensures new tokens are ready before the old one expires + // refreshLeadTime: 2s buffer before leeway starts. Token fetches typically complete in ~100ms, + // so 2s provides ample margin for the refresh to complete before the token enters the leeway period. + const refreshLeadTime = 2; + const minLeeway = POLLER_INTERVAL_IN_MS / 1000; // Minimum is poller interval (5s) + const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); + const refreshFireTime = expiresIn - leeway - refreshLeadTime; + + if (refreshFireTime > 0 && entry.onRefresh) { + const refreshTimeoutId = setTimeout(() => { + entry.onRefresh?.(); + }, refreshFireTime * 1000); + + value.refreshTimeoutId = refreshTimeoutId; + + if (typeof (refreshTimeoutId as any).unref === 'function') { + (refreshTimeoutId as any).unref(); + } + } + const channel = broadcastChannel; if (channel && options.broadcast) { const tokenRaw = newToken.getRawString(); diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index b181df2ec74..14c98f834cd 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -96,7 +96,6 @@ async function waitForClerk(): Promise { * @param options - Optional configuration for token retrieval * @param options.template - The name of a JWT template to use * @param options.organizationId - Organization ID to include in the token - * @param options.leewayInSeconds - Number of seconds of leeway for token expiration * @param options.skipCache - Whether to skip the token cache * @returns A Promise that resolves to the session token, or `null` if the user is not signed in * diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index 727c044ad48..597611d0380 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -339,10 +339,9 @@ export interface SessionTask { } export type GetTokenOptions = { - template?: string; organizationId?: string; - leewayInSeconds?: number; skipCache?: boolean; + template?: string; }; /** * @inline diff --git a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js index f78a1ef9b63..0e08fdf6874 100644 --- a/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js +++ b/packages/upgrade/src/codemods/__tests__/__fixtures__/transform-remove-deprecated-props.fixtures.js @@ -256,4 +256,53 @@ export const Provider = ({ children }) => ( ); `, }, + { + name: 'getToken leewayInSeconds removal - direct call', + source: ` +const token = await getToken({ leewayInSeconds: 30 }); + `, + output: ` +const token = await getToken(); + `, + }, + { + name: 'getToken leewayInSeconds removal - member expression', + source: ` +const token = await session.getToken({ leewayInSeconds: 30, template: 'custom' }); + `, + output: ` +const token = await session.getToken({ + template: 'custom' +}); + `, + }, + { + name: 'getToken leewayInSeconds removal - optional chaining', + source: ` +const token = await session?.getToken({ leewayInSeconds: 15, organizationId: 'org_123' }); + `, + output: ` +const token = await session?.getToken({ + organizationId: 'org_123' +}); + `, + }, + { + name: 'getToken leewayInSeconds removal - multiple options', + source: ` +const token = await getToken({ + template: 'my-template', + leewayInSeconds: 20, + organizationId: 'org_abc', + skipCache: true +}); + `, + output: ` +const token = await getToken({ + template: 'my-template', + organizationId: 'org_abc', + skipCache: true +}); + `, + }, ]; diff --git a/packages/upgrade/src/codemods/index.js b/packages/upgrade/src/codemods/index.js index 36be9a1b7a1..ac455f89ac2 100644 --- a/packages/upgrade/src/codemods/index.js +++ b/packages/upgrade/src/codemods/index.js @@ -91,8 +91,9 @@ function renderDeprecatedPropsSummary(stats) { const userButtonCount = stats.userbuttonAfterSignOutPropsRemoved || 0; const hideSlugCount = stats.hideSlugRemoved || 0; const beforeEmitCount = stats.beforeEmitTransformed || 0; + const leewayCount = stats.leewayInSecondsRemoved || 0; - if (!userButtonCount && !hideSlugCount && !beforeEmitCount) { + if (!userButtonCount && !hideSlugCount && !beforeEmitCount && !leewayCount) { return; } @@ -116,5 +117,10 @@ function renderDeprecatedPropsSummary(stats) { console.log(chalk.gray(' The callback now receives an object with session property.')); } + if (leewayCount > 0) { + console.log(chalk.yellow(`• Removed ${leewayCount} leewayInSeconds option(s) from getToken() calls`)); + console.log(chalk.gray(' Tokens are now automatically refreshed in the background with a fixed threshold.')); + } + console.log(''); } diff --git a/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs b/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs index 33b5f7783cb..cab17b7d7da 100644 --- a/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs +++ b/packages/upgrade/src/codemods/transform-remove-deprecated-props.cjs @@ -122,6 +122,10 @@ module.exports = function transformDeprecatedProps({ source }, { jscodeshift: j, dirty = true; } + if (removeGetTokenLeewayInSeconds(root, j, stats)) { + dirty = true; + } + return dirty ? root.toSource() : undefined; }; @@ -557,3 +561,52 @@ function transformClerkJsVariantToPrefetchUI(j, jsxNode, stats) { return true; } + +/** + * Removes leewayInSeconds from getToken() calls. + * The leewayInSeconds option has been removed from the public API in favor of + * automatic background token refresh with a fixed threshold. + */ +function removeGetTokenLeewayInSeconds(root, j, stats) { + let changed = false; + + root + .find(j.CallExpression) + .filter(path => isGetTokenCall(path.node.callee)) + .forEach(path => { + const [args0] = path.node.arguments; + if (!args0 || args0.type !== 'ObjectExpression') { + return; + } + + const leewayIndex = args0.properties.findIndex(prop => isPropertyNamed(prop, 'leewayInSeconds')); + if (leewayIndex === -1) { + return; + } + + args0.properties.splice(leewayIndex, 1); + changed = true; + stats('leewayInSecondsRemoved'); + + // If the object is now empty, remove the entire argument + if (args0.properties.length === 0) { + path.node.arguments = []; + } + }); + + return changed; +} + +function isGetTokenCall(callee) { + if (!callee) { + return false; + } + if (callee.type === 'Identifier') { + return callee.name === 'getToken'; + } + if (callee.type === 'MemberExpression' || callee.type === 'OptionalMemberExpression') { + const property = callee.property; + return property && property.type === 'Identifier' && property.name === 'getToken'; + } + return false; +} diff --git a/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md new file mode 100644 index 00000000000..fd1171d55a5 --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/gettoken-stale-while-revalidate.md @@ -0,0 +1,39 @@ +--- +title: '`getToken` now uses proactive background refresh' +matcher: + - 'getToken' + - 'session\.getToken' +category: 'behavior-change' +warning: true +--- + +`session.getToken()` now implements a stale-while-revalidate pattern that improves performance by returning cached tokens immediately while refreshing them in the background when they're close to expiration. + +### How it works + +1. When a token is within 15 seconds of expiration, `getToken()` returns the valid cached token immediately +2. A background refresh is triggered automatically to fetch a fresh token +3. Subsequent calls receive the new token once the background refresh completes + +### Benefits + +- **Reduced latency**: No more waiting for token refresh on every call near expiration +- **Better user experience**: API calls proceed immediately with valid (though expiring) tokens +- **Automatic refresh**: Fresh tokens are ready before the old ones expire + +### Cross-tab synchronization + +Token updates are automatically synchronized across browser tabs using `BroadcastChannel`. When one tab refreshes a token, other tabs receive the update automatically. + +### Example + +```js +// Token is cached and valid but expiring in 10 seconds +// Core 2 behavior: Would block and fetch new token +// Core 3 behavior: Returns cached token immediately, refreshes in background +const token = await session.getToken(); +``` + +### Compatibility + +This is a transparent improvement - no code changes are required. Your existing `getToken()` calls benefit automatically.