Skip to content

Commit 0e812a2

Browse files
committed
feat(clerk-js): add timer-based proactive token refresh
Adds a proactive refresh mechanism that triggers token refresh before the token enters the leeway window. This prevents getToken() from blocking when tokens are about to expire. The timer fires 2 seconds before the leeway period (at 43 seconds for 60-second tokens), giving time for the background fetch to complete. If the timer doesn't fire (e.g., background tab), we fall back to the existing blocking behavior. Key changes: - Add onExpiringSoon callback to TokenCacheEntry - Schedule proactive refresh timer when caching tokens - Pass callback in Session._getToken() and #hydrateCache()
1 parent fd195c1 commit 0e812a2

3 files changed

Lines changed: 296 additions & 1 deletion

File tree

packages/clerk-js/src/core/__tests__/tokenCache.test.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -762,6 +762,221 @@ describe('SessionTokenCache', () => {
762762
});
763763
});
764764

765+
describe('proactive refresh timer', () => {
766+
it('fires onExpiringSoon callback at REFRESH_BUFFER seconds before leeway zone', async () => {
767+
const nowSeconds = Math.floor(Date.now() / 1000);
768+
const jwt = createJwtWithTtl(nowSeconds, 60);
769+
770+
const token = new Token({
771+
id: 'proactive-refresh-token',
772+
jwt,
773+
object: 'token',
774+
});
775+
776+
const tokenResolver = Promise.resolve<TokenResource>(token);
777+
const onExpiringSoon = vi.fn();
778+
const key = { tokenId: 'proactive-refresh-token' };
779+
780+
SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon });
781+
await tokenResolver;
782+
783+
// Timer should fire at: expiresIn - LEEWAY - SYNC_LEEWAY - REFRESH_BUFFER = 60 - 10 - 5 - 2 = 43s
784+
expect(onExpiringSoon).not.toHaveBeenCalled();
785+
786+
// Advance to just before the timer (42s)
787+
vi.advanceTimersByTime(42 * 1000);
788+
expect(onExpiringSoon).not.toHaveBeenCalled();
789+
790+
// Advance 1 more second to hit the timer (43s)
791+
vi.advanceTimersByTime(1 * 1000);
792+
expect(onExpiringSoon).toHaveBeenCalledTimes(1);
793+
});
794+
795+
it('does not call onExpiringSoon if entry was replaced before timer fires', async () => {
796+
const nowSeconds = Math.floor(Date.now() / 1000);
797+
const jwt1 = createJwtWithTtl(nowSeconds, 60);
798+
const jwt2 = createJwtWithTtl(nowSeconds, 60);
799+
800+
const token1 = new Token({ id: 'replaced-token', jwt: jwt1, object: 'token' });
801+
const token2 = new Token({ id: 'replaced-token', jwt: jwt2, object: 'token' });
802+
803+
const resolver1 = Promise.resolve<TokenResource>(token1);
804+
const resolver2 = Promise.resolve<TokenResource>(token2);
805+
const onExpiringSoon1 = vi.fn();
806+
const onExpiringSoon2 = vi.fn();
807+
const key = { tokenId: 'replaced-token' };
808+
809+
// Set first entry
810+
SessionTokenCache.set({ ...key, tokenResolver: resolver1, onExpiringSoon: onExpiringSoon1 });
811+
await resolver1;
812+
813+
// Advance time partway (20s)
814+
vi.advanceTimersByTime(20 * 1000);
815+
816+
// Replace with new entry before timer fires
817+
SessionTokenCache.set({ ...key, tokenResolver: resolver2, onExpiringSoon: onExpiringSoon2 });
818+
await resolver2;
819+
820+
// Advance to when original timer would fire (23s more = 43s total from first set)
821+
vi.advanceTimersByTime(23 * 1000);
822+
823+
// Original callback should NOT be called (entry was replaced)
824+
expect(onExpiringSoon1).not.toHaveBeenCalled();
825+
826+
// New callback should NOT be called yet (only 23s since second set, need 43s)
827+
expect(onExpiringSoon2).not.toHaveBeenCalled();
828+
829+
// Advance 20 more seconds (43s from second set)
830+
vi.advanceTimersByTime(20 * 1000);
831+
expect(onExpiringSoon2).toHaveBeenCalledTimes(1);
832+
});
833+
834+
it('returns old token while proactive refresh is in progress (fetch not complete)', async () => {
835+
const nowSeconds = Math.floor(Date.now() / 1000);
836+
const jwt1 = createJwtWithTtl(nowSeconds, 60);
837+
838+
const token1 = new Token({ id: 'proactive-test', jwt: jwt1, object: 'token' });
839+
const resolver1 = Promise.resolve<TokenResource>(token1);
840+
const key = { tokenId: 'proactive-test' };
841+
842+
let refreshTriggered = false;
843+
let resolveNewToken: (token: TokenResource) => void;
844+
const newTokenPromise = new Promise<TokenResource>(resolve => {
845+
resolveNewToken = resolve;
846+
});
847+
848+
SessionTokenCache.set({
849+
...key,
850+
tokenResolver: resolver1,
851+
onExpiringSoon: () => {
852+
refreshTriggered = true;
853+
// Simulate background refresh that takes time - DON'T update cache yet
854+
// In real code, Session.#proactiveRefresh only updates cache after fetch completes
855+
},
856+
});
857+
await resolver1;
858+
859+
// Advance to timer fire time (43s)
860+
vi.advanceTimersByTime(43 * 1000);
861+
expect(refreshTriggered).toBe(true);
862+
863+
// At t=44 (between timer at 43s and leeway at 45s)
864+
// The old token is still in cache because proactive refresh hasn't completed yet
865+
vi.advanceTimersByTime(1 * 1000);
866+
867+
const cached = SessionTokenCache.get(key);
868+
expect(cached).toBeDefined();
869+
870+
// Should still be the OLD token (iat = nowSeconds, not nowSeconds + 44)
871+
const resolvedToken = await cached!.tokenResolver;
872+
expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds);
873+
});
874+
875+
it('returns new token after proactive refresh completes', async () => {
876+
const nowSeconds = Math.floor(Date.now() / 1000);
877+
const jwt1 = createJwtWithTtl(nowSeconds, 60);
878+
879+
const token1 = new Token({ id: 'proactive-complete', jwt: jwt1, object: 'token' });
880+
const resolver1 = Promise.resolve<TokenResource>(token1);
881+
const key = { tokenId: 'proactive-complete' };
882+
883+
let refreshTriggered = false;
884+
885+
SessionTokenCache.set({
886+
...key,
887+
tokenResolver: resolver1,
888+
onExpiringSoon: () => {
889+
refreshTriggered = true;
890+
// Simulate proactive refresh completing - update cache with new token
891+
const newJwt = createJwtWithTtl(nowSeconds + 43, 60);
892+
const newToken = new Token({ id: 'proactive-complete', jwt: newJwt, object: 'token' });
893+
SessionTokenCache.set({ ...key, tokenResolver: Promise.resolve(newToken) });
894+
},
895+
});
896+
await resolver1;
897+
898+
// Advance to timer fire time (43s) - refresh completes immediately in this test
899+
vi.advanceTimersByTime(43 * 1000);
900+
expect(refreshTriggered).toBe(true);
901+
902+
// At t=44, the new token should be in cache
903+
vi.advanceTimersByTime(1 * 1000);
904+
905+
const cached = SessionTokenCache.get(key);
906+
expect(cached).toBeDefined();
907+
908+
// Should be the NEW token
909+
const resolvedToken = await cached!.tokenResolver;
910+
expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds + 43);
911+
});
912+
913+
it('does not schedule refresh timer when refreshDelay would be negative', async () => {
914+
const nowSeconds = Math.floor(Date.now() / 1000);
915+
// Token with only 10s TTL - refreshDelay = 10 - 10 - 5 - 2 = -7 (negative)
916+
const jwt = createJwtWithTtl(nowSeconds, 10);
917+
918+
const token = new Token({ id: 'short-lived-token', jwt, object: 'token' });
919+
const tokenResolver = Promise.resolve<TokenResource>(token);
920+
const onExpiringSoon = vi.fn();
921+
const key = { tokenId: 'short-lived-token' };
922+
923+
SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon });
924+
await tokenResolver;
925+
926+
// Advance past token expiration
927+
vi.advanceTimersByTime(15 * 1000);
928+
929+
// Callback should never be called for tokens that expire too soon
930+
expect(onExpiringSoon).not.toHaveBeenCalled();
931+
});
932+
933+
it('clears refresh timer when entry is deleted via clear()', async () => {
934+
const nowSeconds = Math.floor(Date.now() / 1000);
935+
const jwt = createJwtWithTtl(nowSeconds, 60);
936+
937+
const token = new Token({ id: 'cleared-token', jwt, object: 'token' });
938+
const tokenResolver = Promise.resolve<TokenResource>(token);
939+
const onExpiringSoon = vi.fn();
940+
const key = { tokenId: 'cleared-token' };
941+
942+
SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon });
943+
await tokenResolver;
944+
945+
// Clear the cache
946+
SessionTokenCache.clear();
947+
948+
// Advance to when timer would have fired
949+
vi.advanceTimersByTime(43 * 1000);
950+
951+
// Callback should NOT be called (timer was cleared)
952+
expect(onExpiringSoon).not.toHaveBeenCalled();
953+
});
954+
955+
it('refresh timer fires before token enters leeway zone', async () => {
956+
const nowSeconds = Math.floor(Date.now() / 1000);
957+
const jwt = createJwtWithTtl(nowSeconds, 60);
958+
959+
const token = new Token({ id: 'timing-token', jwt, object: 'token' });
960+
const tokenResolver = Promise.resolve<TokenResource>(token);
961+
const onExpiringSoon = vi.fn();
962+
const key = { tokenId: 'timing-token' };
963+
964+
SessionTokenCache.set({ ...key, tokenResolver, onExpiringSoon });
965+
await tokenResolver;
966+
967+
// At t=43, callback fires (before leeway zone at t=45)
968+
// At t=46, token is in leeway zone and get() returns undefined
969+
vi.advanceTimersByTime(46 * 1000);
970+
971+
// The callback WAS called at t=43
972+
expect(onExpiringSoon).toHaveBeenCalledTimes(1);
973+
974+
// But now the token is in leeway zone
975+
const cached = SessionTokenCache.get(key);
976+
expect(cached).toBeUndefined();
977+
});
978+
});
979+
765980
describe('multi-session isolation', () => {
766981
it('stores tokens from different session IDs separately without interference', async () => {
767982
const nowSeconds = Math.floor(Date.now() / 1000);

packages/clerk-js/src/core/resources/Session.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,48 @@ export class Session extends BaseResource implements SessionResource {
135135
SessionTokenCache.set({
136136
tokenId: this.#getCacheId(),
137137
tokenResolver: Promise.resolve(token),
138+
onExpiringSoon: () => this.#proactiveRefresh(),
138139
});
139140
}
140141
};
141142

143+
/**
144+
* Proactively refreshes a token in the background without blocking getToken() calls.
145+
* Unlike _getToken({ skipCache: true }), this does NOT replace the cache entry until
146+
* the new token is actually fetched. This allows concurrent getToken() calls to return
147+
* the existing (still valid) cached token while the refresh is in progress.
148+
*/
149+
#proactiveRefresh = (template?: string, organizationId?: string | null) => {
150+
const path = template ? `${this.path()}/tokens/${template}` : `${this.path()}/tokens`;
151+
const resolvedOrgId = typeof organizationId === 'undefined' ? this.lastActiveOrganizationId : organizationId;
152+
const params: Record<string, string | null> = template ? {} : { organizationId: resolvedOrgId };
153+
const tokenId = this.#getCacheId(template, organizationId);
154+
155+
void Token.create(path, params)
156+
.then(newToken => {
157+
// Only update cache AFTER fetch completes - this is the key difference from _getToken
158+
SessionTokenCache.set({
159+
tokenId,
160+
tokenResolver: Promise.resolve(newToken),
161+
onExpiringSoon: () => this.#proactiveRefresh(template, organizationId),
162+
});
163+
164+
// Dispatch events if this is a session token for the active organization
165+
const shouldDispatchTokenUpdate = !template && resolvedOrgId === this.lastActiveOrganizationId;
166+
if (shouldDispatchTokenUpdate) {
167+
eventBus.emit(events.TokenUpdate, { token: newToken });
168+
169+
if (newToken.jwt) {
170+
this.lastActiveToken = newToken;
171+
eventBus.emit(events.SessionTokenResolved, null);
172+
}
173+
}
174+
})
175+
.catch(() => {
176+
// Ignore errors - the regular getToken flow will handle them when called
177+
});
178+
};
179+
142180
// If it's a session token, retrieve it with their session id, otherwise it's a jwt template token
143181
// and retrieve it using the session id concatenated with the jwt template name.
144182
// e.g. session id is 'sess_abc12345' and jwt template name is 'haris'
@@ -407,7 +445,11 @@ export class Session extends BaseResource implements SessionResource {
407445
}
408446
throw e;
409447
});
410-
SessionTokenCache.set({ tokenId, tokenResolver });
448+
SessionTokenCache.set({
449+
tokenId,
450+
tokenResolver,
451+
onExpiringSoon: () => this.#proactiveRefresh(template, organizationId),
452+
});
411453

412454
return tokenResolver.then(token => {
413455
if (shouldDispatchTokenUpdate) {

packages/clerk-js/src/core/tokenCache.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ interface TokenCacheEntry extends TokenCacheKeyJSON {
2323
* Used for expiration and cleanup scheduling.
2424
*/
2525
createdAt?: Seconds;
26+
/**
27+
* Callback invoked when the token is about to enter the leeway period.
28+
* Used to trigger proactive background refresh before getToken() would block.
29+
*/
30+
onExpiringSoon?: () => void;
2631
/**
2732
* Promise that resolves to the TokenResource.
2833
* May be pending and should be awaited before accessing token data.
@@ -39,6 +44,7 @@ interface TokenCacheValue {
3944
createdAt: Seconds;
4045
entry: TokenCacheEntry;
4146
expiresIn?: Seconds;
47+
refreshTimeoutId?: ReturnType<typeof setTimeout>;
4248
timeoutId?: ReturnType<typeof setTimeout>;
4349
}
4450

@@ -85,6 +91,8 @@ const DELIMITER = '::';
8591
const LEEWAY = 10;
8692
// This value should have the same value as the INTERVAL_IN_MS in SessionCookiePoller
8793
const SYNC_LEEWAY = 5;
94+
// Buffer time before leeway to trigger proactive refresh, giving time for fetch to complete
95+
const REFRESH_BUFFER = 2;
8896

8997
const BROADCAST = { broadcast: true };
9098
const NO_BROADCAST = { broadcast: false };
@@ -170,6 +178,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
170178
if (value.timeoutId !== undefined) {
171179
clearTimeout(value.timeoutId);
172180
}
181+
if (value.refreshTimeoutId !== undefined) {
182+
clearTimeout(value.refreshTimeoutId);
183+
}
173184
});
174185
cache.clear();
175186
};
@@ -196,6 +207,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
196207
if (value.timeoutId !== undefined) {
197208
clearTimeout(value.timeoutId);
198209
}
210+
if (value.refreshTimeoutId !== undefined) {
211+
clearTimeout(value.refreshTimeoutId);
212+
}
199213
cache.delete(cacheKey.toKey());
200214
return;
201215
}
@@ -324,6 +338,9 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
324338
if (cachedValue.timeoutId !== undefined) {
325339
clearTimeout(cachedValue.timeoutId);
326340
}
341+
if (cachedValue.refreshTimeoutId !== undefined) {
342+
clearTimeout(cachedValue.refreshTimeoutId);
343+
}
327344
cache.delete(key);
328345
}
329346
};
@@ -350,6 +367,27 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
350367
(timeoutId as any).unref();
351368
}
352369

370+
// Schedule proactive refresh timer to fire before token enters the leeway zone.
371+
// This allows background refresh so getToken() doesn't block when called later.
372+
if (entry.onExpiringSoon) {
373+
const refreshDelay = expiresIn - LEEWAY - SYNC_LEEWAY - REFRESH_BUFFER;
374+
if (refreshDelay > 0) {
375+
const refreshTimeoutId = setTimeout(() => {
376+
// Only call if this entry is still the current one in cache
377+
const currentValue = cache.get(key);
378+
if (currentValue === value) {
379+
entry.onExpiringSoon?.();
380+
}
381+
}, refreshDelay * 1000);
382+
383+
value.refreshTimeoutId = refreshTimeoutId;
384+
385+
if (typeof (refreshTimeoutId as any).unref === 'function') {
386+
(refreshTimeoutId as any).unref();
387+
}
388+
}
389+
}
390+
353391
const channel = broadcastChannel;
354392
if (channel && options.broadcast) {
355393
const tokenRaw = newToken.getRawString();

0 commit comments

Comments
 (0)