diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index a5d7f892bb8..387243b54b8 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -762,6 +762,221 @@ describe('SessionTokenCache', () => { }); }); + describe('proactive refresh timer', () => { + it('fires onExpiringSoon callback at REFRESH_BUFFER seconds before leeway zone', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ + id: 'proactive-refresh-token', + jwt, + object: 'token', + }); + + const tokenResolver = Promise.resolve(token); + const onExpiringSoon = vi.fn(); + const key = { tokenId: 'proactive-refresh-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon }); + await tokenResolver; + + // Timer should fire at: expiresIn - LEEWAY - SYNC_LEEWAY - REFRESH_BUFFER = 60 - 10 - 5 - 2 = 43s + expect(onExpiringSoon).not.toHaveBeenCalled(); + + // Advance to just before the timer (42s) + vi.advanceTimersByTime(42 * 1000); + expect(onExpiringSoon).not.toHaveBeenCalled(); + + // Advance 1 more second to hit the timer (43s) + vi.advanceTimersByTime(1 * 1000); + expect(onExpiringSoon).toHaveBeenCalledTimes(1); + }); + + it('does not call onExpiringSoon if entry was replaced before timer fires', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt1 = createJwtWithTtl(nowSeconds, 60); + const jwt2 = createJwtWithTtl(nowSeconds, 60); + + const token1 = new Token({ id: 'replaced-token', jwt: jwt1, object: 'token' }); + const token2 = new Token({ id: 'replaced-token', jwt: jwt2, object: 'token' }); + + const resolver1 = Promise.resolve(token1); + const resolver2 = Promise.resolve(token2); + const onExpiringSoon1 = vi.fn(); + const onExpiringSoon2 = vi.fn(); + const key = { tokenId: 'replaced-token' }; + + // Set first entry + SessionTokenCache.set({ ...key, tokenResolver: resolver1, onExpiringSoon: onExpiringSoon1 }); + await resolver1; + + // Advance time partway (20s) + vi.advanceTimersByTime(20 * 1000); + + // Replace with new entry before timer fires + SessionTokenCache.set({ ...key, tokenResolver: resolver2, onExpiringSoon: onExpiringSoon2 }); + await resolver2; + + // Advance to when original timer would fire (23s more = 43s total from first set) + vi.advanceTimersByTime(23 * 1000); + + // Original callback should NOT be called (entry was replaced) + expect(onExpiringSoon1).not.toHaveBeenCalled(); + + // New callback should NOT be called yet (only 23s since second set, need 43s) + expect(onExpiringSoon2).not.toHaveBeenCalled(); + + // Advance 20 more seconds (43s from second set) + vi.advanceTimersByTime(20 * 1000); + expect(onExpiringSoon2).toHaveBeenCalledTimes(1); + }); + + it('returns old token while proactive refresh is in progress (fetch not complete)', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt1 = createJwtWithTtl(nowSeconds, 60); + + const token1 = new Token({ id: 'proactive-test', jwt: jwt1, object: 'token' }); + const resolver1 = Promise.resolve(token1); + const key = { tokenId: 'proactive-test' }; + + let refreshTriggered = false; + let resolveNewToken: (token: TokenResource) => void; + const newTokenPromise = new Promise(resolve => { + resolveNewToken = resolve; + }); + + SessionTokenCache.set({ + ...key, + tokenResolver: resolver1, + onExpiringSoon: () => { + refreshTriggered = true; + // Simulate background refresh that takes time - DON'T update cache yet + // In real code, Session.#proactiveRefresh only updates cache after fetch completes + }, + }); + await resolver1; + + // Advance to timer fire time (43s) + vi.advanceTimersByTime(43 * 1000); + expect(refreshTriggered).toBe(true); + + // At t=44 (between timer at 43s and leeway at 45s) + // The old token is still in cache because proactive refresh hasn't completed yet + vi.advanceTimersByTime(1 * 1000); + + const cached = SessionTokenCache.get(key); + expect(cached).toBeDefined(); + + // Should still be the OLD token (iat = nowSeconds, not nowSeconds + 44) + const resolvedToken = await cached!.tokenResolver; + expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds); + }); + + it('returns new token after proactive refresh completes', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt1 = createJwtWithTtl(nowSeconds, 60); + + const token1 = new Token({ id: 'proactive-complete', jwt: jwt1, object: 'token' }); + const resolver1 = Promise.resolve(token1); + const key = { tokenId: 'proactive-complete' }; + + let refreshTriggered = false; + + SessionTokenCache.set({ + ...key, + tokenResolver: resolver1, + onExpiringSoon: () => { + refreshTriggered = true; + // Simulate proactive refresh completing - update cache with new token + const newJwt = createJwtWithTtl(nowSeconds + 43, 60); + const newToken = new Token({ id: 'proactive-complete', jwt: newJwt, object: 'token' }); + SessionTokenCache.set({ ...key, tokenResolver: Promise.resolve(newToken) }); + }, + }); + await resolver1; + + // Advance to timer fire time (43s) - refresh completes immediately in this test + vi.advanceTimersByTime(43 * 1000); + expect(refreshTriggered).toBe(true); + + // At t=44, the new token should be in cache + vi.advanceTimersByTime(1 * 1000); + + const cached = SessionTokenCache.get(key); + expect(cached).toBeDefined(); + + // Should be the NEW token + const resolvedToken = await cached!.tokenResolver; + expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds + 43); + }); + + it('does not schedule refresh timer when refreshDelay would be negative', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + // Token with only 10s TTL - refreshDelay = 10 - 10 - 5 - 2 = -7 (negative) + const jwt = createJwtWithTtl(nowSeconds, 10); + + const token = new Token({ id: 'short-lived-token', jwt, object: 'token' }); + const tokenResolver = Promise.resolve(token); + const onExpiringSoon = vi.fn(); + const key = { tokenId: 'short-lived-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon }); + await tokenResolver; + + // Advance past token expiration + vi.advanceTimersByTime(15 * 1000); + + // Callback should never be called for tokens that expire too soon + expect(onExpiringSoon).not.toHaveBeenCalled(); + }); + + it('clears refresh timer when entry is deleted via clear()', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ id: 'cleared-token', jwt, object: 'token' }); + const tokenResolver = Promise.resolve(token); + const onExpiringSoon = vi.fn(); + const key = { tokenId: 'cleared-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon }); + await tokenResolver; + + // Clear the cache + SessionTokenCache.clear(); + + // Advance to when timer would have fired + vi.advanceTimersByTime(43 * 1000); + + // Callback should NOT be called (timer was cleared) + expect(onExpiringSoon).not.toHaveBeenCalled(); + }); + + it('refresh timer fires before token enters leeway zone', async () => { + const nowSeconds = Math.floor(Date.now() / 1000); + const jwt = createJwtWithTtl(nowSeconds, 60); + + const token = new Token({ id: 'timing-token', jwt, object: 'token' }); + const tokenResolver = Promise.resolve(token); + const onExpiringSoon = vi.fn(); + const key = { tokenId: 'timing-token' }; + + SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon }); + await tokenResolver; + + // At t=43, callback fires (before leeway zone at t=45) + // At t=46, token is in leeway zone and get() returns undefined + vi.advanceTimersByTime(46 * 1000); + + // The callback WAS called at t=43 + expect(onExpiringSoon).toHaveBeenCalledTimes(1); + + // But now the token is in leeway zone + const cached = SessionTokenCache.get(key); + expect(cached).toBeUndefined(); + }); + }); + describe('multi-session isolation', () => { it('stores tokens from different session IDs separately without interference', async () => { const nowSeconds = Math.floor(Date.now() / 1000); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..f4c402ba6a0 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -135,10 +135,48 @@ export class Session extends BaseResource implements SessionResource { SessionTokenCache.set({ tokenId: this.#getCacheId(), tokenResolver: Promise.resolve(token), + onExpiringSoon: () => this.#proactiveRefresh(), }); } }; + /** + * Proactively refreshes a token in the background without blocking getToken() calls. + * Unlike _getToken({ skipCache: true }), this does NOT replace the cache entry until + * the new token is actually fetched. This allows concurrent getToken() calls to return + * the existing (still valid) cached token while the refresh is in progress. + */ + #proactiveRefresh = (template?: string, organizationId?: string | null) => { + const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`; + const resolvedOrgId = typeof organizationId === 'undefined' ? this.lastActiveOrganizationId : organizationId; + const params: Record = template ? {} : { organizationId: resolvedOrgId }; + const tokenId = this.#getCacheId(template, organizationId); + + void Token.create(path, params) + .then(newToken => { + // Only update cache AFTER fetch completes - this is the key difference from _getToken + SessionTokenCache.set({ + tokenId, + tokenResolver: Promise.resolve(newToken), + onExpiringSoon: () => this.#proactiveRefresh(template, organizationId), + }); + + // Dispatch events if this is a session token for the active organization + const shouldDispatchTokenUpdate = !template && resolvedOrgId === this.lastActiveOrganizationId; + if (shouldDispatchTokenUpdate) { + eventBus.emit(events.TokenUpdate, { token: newToken }); + + if (newToken.jwt) { + this.lastActiveToken = newToken; + eventBus.emit(events.SessionTokenResolved, null); + } + } + }) + .catch(() => { + // Ignore errors - the regular getToken flow will handle them when called + }); + }; + // If it's a session token, retrieve it with their session id, otherwise it's a jwt template token // and retrieve it using the session id concatenated with the jwt template name. // e.g. session id is 'sess_abc12345' and jwt template name is 'haris' @@ -407,7 +445,11 @@ export class Session extends BaseResource implements SessionResource { } throw e; }); - SessionTokenCache.set({ tokenId, tokenResolver }); + SessionTokenCache.set({ + tokenId, + tokenResolver, + onExpiringSoon: () => this.#proactiveRefresh(template, organizationId), + }); return tokenResolver.then(token => { if (shouldDispatchTokenUpdate) { 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..996cfa67681 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -4,6 +4,16 @@ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vite import { clerkMock, createUser, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; +/** + * Creates a JWT string with the specified iat (issued at) and ttl (time to live). + * The token will expire at iat + ttl seconds. + */ +function createJwtWithTtl(iatSeconds: number, ttlSeconds: number): string { + const payload = { exp: iatSeconds + ttlSeconds, iat: iatSeconds, sid: 'session_1', sub: 'user_1' }; + const payloadB64 = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${payloadB64}.signature`; +} + import { eventBus } from '../../events'; import { createFapiClient } from '../../fapiClient'; import { SessionTokenCache } from '../../tokenCache'; @@ -1233,4 +1243,279 @@ describe('Session', () => { expect(fetchSpy).toHaveBeenCalledTimes(1); }); }); + + /** + * Proactive Token Refresh Tests + * + * Token timing (for 60-second tokens): + * - LEEWAY = 10s (token considered "expiring soon") + * - SYNC_LEEWAY = 5s (buffer for cookie polling) + * - REFRESH_BUFFER = 2s (buffer before leeway) + * - Leeway zone starts at: 60 - 10 - 5 = 45 seconds + * - Proactive timer fires at: 60 - 10 - 5 - 2 = 43 seconds + */ + describe('proactive token refresh behavior', () => { + let fetchSpy: ReturnType; + let dispatchSpy: ReturnType; + + // Use a fixed timestamp for predictable timing + const BASE_TIME_SECONDS = 1700000000; + const TOKEN_TTL = 60; + + beforeEach(() => { + SessionTokenCache.clear(); + dispatchSpy = vi.spyOn(eventBus, 'emit'); + fetchSpy = vi.spyOn(BaseResource, '_fetch' as any); + BaseResource.clerk = clerkMock() as any; + }); + + afterEach(() => { + dispatchSpy?.mockRestore(); + fetchSpy?.mockRestore(); + BaseResource.clerk = null as any; + }); + + it('returns cached token before proactive timer fires (t < 43)', async () => { + // Token issued at BASE_TIME, expires at BASE_TIME + 60 + const jwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + fetchSpy.mockClear(); + + // Advance to t=40 (before the proactive timer at t=43) + vi.advanceTimersByTime(40 * 1000); + + const token = await session.getToken(); + + expect(token).toEqual(jwt); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns OLD token while proactive fetch is in progress (43 < t < 45)', async () => { + const jwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + // Create a promise that never resolves during this test (simulating slow network) + let resolveProactiveFetch: (value: any) => void; + const pendingPromise = new Promise(resolve => { + resolveProactiveFetch = resolve; + }); + fetchSpy.mockReturnValue(pendingPromise); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Allow the initial cache hydration to set up the timer + await Promise.resolve(); + + fetchSpy.mockClear(); + fetchSpy.mockReturnValue(pendingPromise); + + // Advance to t=43 - proactive timer fires, starting background fetch + vi.advanceTimersByTime(43 * 1000); + + // Advance to t=44 (still before leeway at t=45) + vi.advanceTimersByTime(1 * 1000); + + // Call getToken - should return OLD token instantly (non-blocking) + const token = await session.getToken(); + + expect(token).toEqual(jwt); + // Only the proactive fetch should have been triggered + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Clean up the pending promise + resolveProactiveFetch!({ object: 'token', jwt: createJwtWithTtl(BASE_TIME_SECONDS + 43, TOKEN_TTL) }); + }); + + it('returns NEW token after proactive fetch completes (43 < t < 45)', async () => { + const oldJwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + const newJwt = createJwtWithTtl(BASE_TIME_SECONDS + 43, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: oldJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Allow the initial cache hydration to set up the timer + await Promise.resolve(); + + fetchSpy.mockClear(); + + // Mock the proactive fetch to return new token immediately + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: newJwt }); + + // Advance to t=43 - proactive timer fires, fetch completes immediately + vi.advanceTimersByTime(43 * 1000); + + // Allow the proactive fetch promise to resolve and cache update to complete + await Promise.resolve(); + await Promise.resolve(); + + fetchSpy.mockClear(); + + // Advance to t=44 + vi.advanceTimersByTime(1 * 1000); + + // Call getToken - should return NEW token from cache + const token = await session.getToken(); + + expect(token).toEqual(newJwt); + // No additional API call should be made + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns NEW token in leeway zone when proactive fetch completed (t >= 45)', async () => { + const oldJwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + const newJwt = createJwtWithTtl(BASE_TIME_SECONDS + 43, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: oldJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Allow the initial cache hydration to set up the timer + await Promise.resolve(); + + fetchSpy.mockClear(); + + // Mock the proactive fetch to return new token + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: newJwt }); + + // Advance to t=43 - proactive timer fires + vi.advanceTimersByTime(43 * 1000); + + // Allow the proactive fetch promise to resolve and cache update to complete + await Promise.resolve(); + await Promise.resolve(); + + fetchSpy.mockClear(); + + // Advance to t=46 (old token would be in leeway zone, but new token is fresh) + vi.advanceTimersByTime(3 * 1000); + + const token = await session.getToken(); + + expect(token).toEqual(newJwt); + // No additional API call needed - new token is still fresh + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('blocks and fetches new token in leeway zone when proactive fetch failed (t >= 45)', async () => { + const oldJwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + const newJwt = createJwtWithTtl(BASE_TIME_SECONDS + 46, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: oldJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + fetchSpy.mockClear(); + + // Proactive fetch fails silently + fetchSpy.mockRejectedValueOnce(new Error('Network error')); + + // Advance to t=43 - proactive timer fires, fetch fails + vi.advanceTimersByTime(43 * 1000); + + // Allow the proactive fetch promise to reject + await vi.runAllTimersAsync(); + + // Second call (from getToken) succeeds + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: newJwt }); + + // Advance to t=46 (in leeway zone) + vi.advanceTimersByTime(3 * 1000); + + const token = await session.getToken(); + + expect(token).toEqual(newJwt); + // Two API calls: proactive fetch (failed) + getToken fetch (success) + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('blocks and fetches new token when timer did not fire (background tab scenario, t >= 45)', async () => { + const oldJwt = createJwtWithTtl(BASE_TIME_SECONDS, TOKEN_TTL); + const newJwt = createJwtWithTtl(BASE_TIME_SECONDS + 46, TOKEN_TTL); + vi.setSystemTime(new Date(BASE_TIME_SECONDS * 1000)); + + // Create session which hydrates the cache with onExpiringSoon callback + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: oldJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + // Allow the initial cache hydration to complete + await Promise.resolve(); + + // Simulate background tab scenario: clear cache completely + // This simulates what happens when the tab was suspended and the cache is empty + SessionTokenCache.clear(); + + fetchSpy.mockClear(); + fetchSpy.mockResolvedValueOnce({ object: 'token', jwt: newJwt }); + + // Advance to t=46 (timer never fired because cache was cleared) + vi.advanceTimersByTime(46 * 1000); + + // getToken() with no cache entry should make an API call + const token = await session.getToken(); + + expect(token).toEqual(newJwt); + // Should make an API call since there's no cache entry + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..8b6cdbb0294 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -23,6 +23,11 @@ interface TokenCacheEntry extends TokenCacheKeyJSON { * Used for expiration and cleanup scheduling. */ createdAt?: Seconds; + /** + * Callback invoked when the token is about to enter the leeway period. + * Used to trigger proactive background refresh before getToken() would block. + */ + onExpiringSoon?: () => void; /** * Promise that resolves to the TokenResource. * May be pending and should be awaited before accessing token data. @@ -39,6 +44,7 @@ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + refreshTimeoutId?: ReturnType; timeoutId?: ReturnType; } @@ -85,6 +91,8 @@ const DELIMITER = '::'; const LEEWAY = 10; // This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller const SYNC_LEEWAY = 5; +// Buffer time before leeway to trigger proactive refresh, giving time for fetch to complete +const REFRESH_BUFFER = 2; const BROADCAST = { broadcast: true }; const NO_BROADCAST = { broadcast: false }; @@ -170,6 +178,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } }); cache.clear(); }; @@ -196,6 +207,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (value.timeoutId !== undefined) { clearTimeout(value.timeoutId); } + if (value.refreshTimeoutId !== undefined) { + clearTimeout(value.refreshTimeoutId); + } cache.delete(cacheKey.toKey()); return; } @@ -324,6 +338,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { if (cachedValue.timeoutId !== undefined) { clearTimeout(cachedValue.timeoutId); } + if (cachedValue.refreshTimeoutId !== undefined) { + clearTimeout(cachedValue.refreshTimeoutId); + } cache.delete(key); } }; @@ -350,6 +367,27 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { (timeoutId as any).unref(); } + // Schedule proactive refresh timer to fire before token enters the leeway zone. + // This allows background refresh so getToken() doesn't block when called later. + if (entry.onExpiringSoon) { + const refreshDelay = expiresIn - LEEWAY - SYNC_LEEWAY - REFRESH_BUFFER; + if (refreshDelay > 0) { + const refreshTimeoutId = setTimeout(() => { + // Only call if this entry is still the current one in cache + const currentValue = cache.get(key); + if (currentValue === value) { + entry.onExpiringSoon?.(); + } + }, refreshDelay * 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();