diff --git a/.changeset/dark-candles-fly.md b/.changeset/dark-candles-fly.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dark-candles-fly.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts index b3a20218c45..d6f14a9b7aa 100644 --- a/packages/clerk-js/src/core/__tests__/tokenCache.test.ts +++ b/packages/clerk-js/src/core/__tests__/tokenCache.test.ts @@ -259,7 +259,7 @@ describe('SessionTokenCache', () => { const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - expect(resultAfterOlder?.entry.resolvedToken?.getRawString()).toBe(newerJwt); + expect(resultAfterOlder?.token?.getRawString()).toBe(newerJwt); }); it('enforces monotonicity: replaces older cached token when a fresher-oiat broadcast arrives', async () => { @@ -284,7 +284,7 @@ describe('SessionTokenCache', () => { await broadcastListener(olderEvent); const resultAfterOlder = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterOlder).toBeDefined(); - expect(resultAfterOlder?.entry.resolvedToken?.getRawString()).toBe(olderJwt); + expect(resultAfterOlder?.token?.getRawString()).toBe(olderJwt); const newerEvent: MessageEvent = { data: { @@ -301,32 +301,32 @@ describe('SessionTokenCache', () => { const resultAfterNewer = SessionTokenCache.get({ tokenId: 'session_123' }); expect(resultAfterNewer).toBeDefined(); - expect(resultAfterNewer?.entry.resolvedToken?.getRawString()).toBe(newerJwt); + expect(resultAfterNewer?.token?.getRawString()).toBe(newerJwt); }); - it('ignores a broadcast staler than the reconciled resolvedToken even when the resolver is staler', async () => { - // The resolver reconcile can leave the entry's resolvedToken FRESHER than the token its + it('ignores a broadcast staler than the reconciled served token even when the resolver is staler', async () => { + // The resolver reconcile can leave the served token FRESHER than the token its // tokenResolver resolves to (a staler resolve keeps the previous token). The broadcast guard - // must compare against resolvedToken (the freshest known), not the staler resolver, otherwise - // a broadcast staler than resolvedToken slips past the guard and runs setInternal, which + // must compare against the served token (the freshest known), not the staler resolver, otherwise + // a broadcast staler than the served token slips past the guard and runs setInternal, which // clears the refresh timer without reinstalling one. const tokenId = 'session_123'; const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // Cache the high-oiat token and let it resolve so resolvedToken = high. + // Cache the high-oiat token and let it resolve so the served token = high. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); // Overwrite with a resolver that resolves to a LOWER-oiat token. The resolver reconciles - // against the previous token, so the entry's resolvedToken stays high while its resolver + // against the previous token, so the served token stays high while its resolver // resolved to low. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); - const beforeEntry = SessionTokenCache.get({ tokenId })?.entry; - expect(beforeEntry?.resolvedToken?.getRawString()).toBe(highRaw); + const before = SessionTokenCache.get({ tokenId }); + expect(before?.token?.getRawString()).toBe(highRaw); // Broadcast a token staler than high (but fresher than low, so the staler resolver would // have let it through under the bug). @@ -344,11 +344,11 @@ describe('SessionTokenCache', () => { await broadcastListener(stalerEvent); - const afterEntry = SessionTokenCache.get({ tokenId })?.entry; - expect(afterEntry?.resolvedToken?.getRawString()).toBe(highRaw); - // The same entry proves the broadcast was dropped before setInternal ran; the bug - // would have replaced it with the broadcast's entry. - expect(afterEntry).toBe(beforeEntry); + const after = SessionTokenCache.get({ tokenId }); + expect(after?.token?.getRawString()).toBe(highRaw); + // The same resolver proves the broadcast was dropped before setInternal ran; the bug + // would have replaced it with the broadcast's resolver. + expect(after?.tokenResolver).toBe(before?.tokenResolver); }); it('successfully updates cache with valid token', () => { @@ -367,7 +367,6 @@ describe('SessionTokenCache', () => { 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 () => { @@ -437,22 +436,22 @@ describe('SessionTokenCache', () => { describe('same-tab monotonic resolve', () => { const tokenId = 'session_123'; - it('keeps the fresher token when a staler set resolves into the slot after it', async () => { + it('keeps the fresher token when a staler set resolves after it', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // A fresher token is cached and resolved, then a staler set() replaces the slot and resolves. - // The staler resolve reconciles against the previous token (carried onto the new slot's + // A fresher token is cached and resolved, then a staler set() replaces the entry and resolves. + // The staler resolve reconciles against the previous token (carried onto the new entry's // freshest token at set time) and loses. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); - it('advances to a fresher token when it resolves into the slot after a staler one', async () => { + it('advances to a fresher token when it resolves after a staler one', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); @@ -462,7 +461,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); it('publishes the later token on a full oiat+iat tie with different raw payloads', async () => { @@ -477,23 +476,23 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(laterRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(laterRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(laterRaw); }); - it('leaves resolvedToken undefined while a replacement resolver is pending, so getToken awaits', async () => { + it('leaves the token undefined while a replacement resolver is pending, so getToken awaits', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); // A set() with a still-pending resolver must NOT synchronously serve the previous token. - // resolvedToken stays undefined during the pending window, so getToken() awaits the resolver + // The token stays undefined during the pending window, so getToken() awaits the resolver // instead of serving stale — matching behavior before the monotonic guard. const pending = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + expect(SessionTokenCache.get({ tokenId })?.token).toBeUndefined(); }); it('does not resurrect a cleared key when its pending resolver settles', async () => { @@ -517,7 +516,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - // A staler, shorter-ttl token resolves into the slot afterward; it must not replace + // A staler, shorter-ttl token resolves afterward; it must not replace // the fresher token's deletion timer with its own short one. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(lowRaw)) }); await tick(); @@ -527,17 +526,17 @@ describe('SessionTokenCache', () => { const result = SessionTokenCache.get({ tokenId }); expect(result).toBeDefined(); - expect(result?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(result?.token?.getRawString()).toBe(highRaw); }); - it('overlapping resolvers, fresh resolves first then stale: slot keeps high and stamps high iat', async () => { + it('overlapping resolvers, fresh resolves first then stale: entry keeps high and stamps high iat', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); // Two concurrent sets, both pending. The first (fresher) is replaced by the second (staler) // before either resolves. When the fresher one resolves it is now foreign, so it only raises - // the live slot's freshest token, leaving resolvedToken undefined. The staler live resolve then - // reconciles against that freshest token and the slot publishes the fresher token. + // the live entry's freshest token, leaving the token undefined. The staler live resolve then + // reconciles against that freshest token and the entry publishes the fresher token. const dHigh = deferred(); const dLow = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: dHigh.promise }); @@ -545,21 +544,21 @@ describe('SessionTokenCache', () => { dHigh.resolve(makeToken(highRaw)); await tick(); - // A foreign resolve never populates the pending slot's resolvedToken. - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + // A foreign resolve never populates the pending entry's served token. + expect(SessionTokenCache.get({ tokenId })?.token).toBeUndefined(); dLow.resolve(makeToken(lowRaw)); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); - // The slot's TTL comes from the published winner, high (exp 1666648370), not low - // (exp 1666648310). A slot timed from low would already be evicted at this point. + // The entry's TTL comes from the published winner, high (exp 1666648370), not low + // (exp 1666648310). An entry timed from low would already be evicted at this point. vi.advanceTimersByTime(50 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); - it('overlapping resolvers, stale resolves first then fresh: slot ends high', async () => { + it('overlapping resolvers, stale resolves first then fresh: entry ends high', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); @@ -573,21 +572,21 @@ describe('SessionTokenCache', () => { dLow.resolve(makeToken(lowRaw)); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + expect(SessionTokenCache.get({ tokenId })?.token).toBeUndefined(); dHigh.resolve(makeToken(highRaw)); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); - it('advances an already-resolved staler slot when a fresher foreign resolve lands late', async () => { + it('advances an already-resolved staler entry when a fresher foreign resolve lands late', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // The live slot (d2) resolves staler first and publishes low. The replaced resolver (d1) - // resolves fresher afterward. Because the slot is no longer pending, the fresher foreign token - // advances resolvedToken to high and re-derives the slot's timers, rather than only raising the + // The live entry (d2) resolves staler first and publishes low. The replaced resolver (d1) + // resolves fresher afterward. Because the entry is no longer pending, the fresher foreign token + // advances the served token to high and re-derives the entry's timers, rather than only raising the // freshest token. const dHigh = deferred(); const dLow = deferred(); @@ -596,42 +595,42 @@ describe('SessionTokenCache', () => { dLow.resolve(makeToken(lowRaw)); await tick(); - // The live slot resolved first, so it publishes the staler token. - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(lowRaw); + // The live entry resolved first, so it publishes the staler token. + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(lowRaw); dHigh.resolve(makeToken(highRaw)); await tick(); - // The late foreign resolve advances the already-resolved slot to the fresher token. - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + // The late foreign resolve advances the already-resolved entry to the fresher token. + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); it('never exposes the freshest token while a replacement is pending, then publishes high once it resolves', async () => { const highRaw = createJwtWithOiat(1666648250, 1666648250, 120); const lowRaw = createJwtWithOiat(1666648190, 1666648190, 120); - // The first set resolves to high, so the slot's resolvedToken and freshest token are both high. + // The first set resolves to high, so the entry's served token and freshest token are both high. SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(highRaw)) }); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); - // A replacement whose resolver never settles must not expose the carried freshest token: the slot - // reads as pending (resolvedToken undefined) so callers keep awaiting the in-flight fetch. + // A replacement whose resolver never settles must not expose the carried freshest token: the entry + // reads as pending (token undefined) so callers keep awaiting the in-flight fetch. const pending = deferred(); SessionTokenCache.set({ tokenId, tokenResolver: pending.promise }); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken).toBeUndefined(); + expect(SessionTokenCache.get({ tokenId })?.token).toBeUndefined(); - // When it finally resolves staler, the own resolve reconciles against the freshest token and the slot + // When it finally resolves staler, the own resolve reconciles against the freshest token and the entry // publishes high. pending.resolve(makeToken(lowRaw)); await tick(); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(highRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(highRaw); }); it('schedules timers from the winner remaining ttl, not a full lifetime from now', async () => { // Aged winner: minted 30s before the mocked now with a 120s lifetime, so 90s // actually remain. Refresh must fire at remaining - leeway - lead time - // (90 - 15 - 2 = 73s) and the slot must be evicted by its real expiry, not a + // (90 - 15 - 2 = 73s) and the entry must be evicted by its real expiry, not a // full 120s from now. const agedRaw = createJwtWithOiat(1666648230, 1666648230, 120); const onRefresh = vi.fn(); @@ -642,7 +641,7 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(74 * 1000); expect(onRefresh).toHaveBeenCalledTimes(1); - // Past the real 90s expiry: the deletion timer has already dropped the slot. + // Past the real 90s expiry: the deletion timer has already dropped the entry. vi.advanceTimersByTime(17 * 1000); expect(SessionTokenCache.size()).toBe(0); }); @@ -667,7 +666,7 @@ describe('SessionTokenCache', () => { const result = SessionTokenCache.get({ tokenId: 'future_token' }); expect(result).toBeDefined(); - expect(result?.entry.tokenId).toBe('future_token'); + expect(result?.tokenResolver).toBe(tokenResolver); }); it('removes token when it has already expired based on duration', async () => { @@ -708,7 +707,7 @@ describe('SessionTokenCache', () => { // 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'); + expect(result?.tokenResolver).toBe(tokenResolver); }); it('returns token when expiresAt is undefined (promise not yet resolved)', () => { @@ -719,7 +718,7 @@ describe('SessionTokenCache', () => { const result = SessionTokenCache.get({ tokenId: 'pending_token' }); expect(result).toBeDefined(); - expect(result?.entry.tokenId).toBe('pending_token'); + expect(result?.tokenResolver).toBe(pendingTokenResolver); }); }); @@ -792,7 +791,7 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(20 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(fresherRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); @@ -825,7 +824,7 @@ describe('SessionTokenCache', () => { expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); - it('keeps the fresher token expiry when it is carried into a later slot', async () => { + it('keeps the fresher token expiry when it is carried into a later entry', async () => { const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); @@ -837,7 +836,7 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(20 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(freshRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); @@ -854,7 +853,7 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(20 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(freshRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); @@ -873,13 +872,13 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(20 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(freshRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); - it('counts a replaced resolver token lifetime from its own set, not the live slot', async () => { + it('counts a replaced resolver token lifetime from its own set, not the live entry', async () => { const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); const dFresh = deferred(); @@ -896,7 +895,7 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(40 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(freshRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); @@ -914,13 +913,13 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(26 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(fresherRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(fresherRaw); vi.advanceTimersByTime(30 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); - it('fires the proactive refresh from the carried token expiry, not the later slot', async () => { + it('fires the proactive refresh from the carried token expiry, not the later entry', async () => { const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); const stalerRaw = createJwtWithOiat(deviceNow, deviceNow, 60); const onRefresh = vi.fn(); @@ -939,7 +938,7 @@ describe('SessionTokenCache', () => { expect(onRefresh).toHaveBeenCalledTimes(1); }); - it('takes the earlier expiry when a replaced resolver delivers the same token after the slot resolved', async () => { + it('takes the earlier expiry when a replaced resolver delivers the same token after the entry resolved', async () => { const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); const dFirst = deferred(); @@ -953,13 +952,13 @@ describe('SessionTokenCache', () => { await tick(); vi.advanceTimersByTime(40 * 1000); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(freshRaw); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(freshRaw); vi.advanceTimersByTime(6 * 1000); expect(SessionTokenCache.get({ tokenId })).toBeUndefined(); }); - it('keeps a pending slot when the token carried into it is close to expiry', async () => { + it('keeps a pending entry when the token carried into it is close to expiry', async () => { const freshRaw = createJwtWithOiat(deviceNow + deviceLag, deviceNow + deviceLag, 60); SessionTokenCache.set({ tokenId, tokenResolver: Promise.resolve(makeToken(freshRaw)) }); @@ -971,7 +970,7 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(8 * 1000); const result = SessionTokenCache.get({ tokenId }); expect(result).toBeDefined(); - expect(result?.entry.resolvedToken).toBeUndefined(); + expect(result?.token).toBeUndefined(); }); }); @@ -1096,7 +1095,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key)).toBeDefined(); expect(SessionTokenCache.size()).toBe(1); SessionTokenCache.clear(); @@ -1139,7 +1138,7 @@ describe('SessionTokenCache', () => { const resultWhilePending = SessionTokenCache.get(key); expect(resultWhilePending).toBeDefined(); - expect(resultWhilePending?.entry.tokenId).toBe('lifecycle-token'); + expect(resultWhilePending?.tokenResolver).toBe(tokenResolver); expect(isResolved).toBe(false); vi.advanceTimersByTime(100); @@ -1148,7 +1147,7 @@ describe('SessionTokenCache', () => { const resultAfterResolved = SessionTokenCache.get(key); expect(isResolved).toBe(true); expect(resultAfterResolved).toBeDefined(); - expect(resultAfterResolved?.entry.tokenId).toBe('lifecycle-token'); + expect(resultAfterResolved?.tokenResolver).toBe(tokenResolver); vi.advanceTimersByTime(60 * 1000); @@ -1206,14 +1205,14 @@ describe('SessionTokenCache', () => { // Token should still be cached and retrievable const result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('no-callback-token'); + expect(result?.tokenResolver).toBe(tokenResolver); // Advance past when timer would fire - nothing should happen vi.advanceTimersByTime(50 * 1000); // Token should still be valid (10s remaining) const stillCached = SessionTokenCache.get(key); - expect(stillCached?.entry.tokenId).toBe('no-callback-token'); + expect(stillCached?.tokenResolver).toBe(tokenResolver); }); it('clears refresh timer when entry is deleted', async () => { @@ -1292,7 +1291,7 @@ describe('SessionTokenCache', () => { // Token should still be retrievable (10s remaining, > 5s poller interval) const result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('still-valid-token'); + expect(result?.tokenResolver).toBe(tokenResolver); }); }); @@ -1476,7 +1475,7 @@ describe('SessionTokenCache', () => { // Token B should still be in cache (identity check in deleteKey prevents stale removal) const result = SessionTokenCache.get(key); expect(result).toBeDefined(); - expect(result?.entry.tokenId).toBe('no-interference-token'); + expect(result?.token).toBe(tokenB); // Token B should survive well past Token A's original expiry vi.advanceTimersByTime(100 * 1000); @@ -1545,12 +1544,12 @@ describe('SessionTokenCache', () => { // 60s remaining let result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('above-cutoff-token'); + expect(result?.tokenResolver).toBe(tokenResolver); // 10s remaining (above 5s cutoff) vi.advanceTimersByTime(50 * 1000); result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('above-cutoff-token'); + expect(result?.tokenResolver).toBe(tokenResolver); }); it('forces synchronous refresh when token has less than poller interval remaining', async () => { @@ -1572,7 +1571,7 @@ describe('SessionTokenCache', () => { // 6s remaining (just above 5s cutoff) vi.advanceTimersByTime(54 * 1000); let result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('hard-cutoff-token'); + expect(result?.tokenResolver).toBe(tokenResolver); // 4s remaining (below 5s cutoff) - forces sync refresh vi.advanceTimersByTime(2 * 1000); @@ -1598,7 +1597,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key)).toBeDefined(); vi.advanceTimersByTime(30 * 1000); @@ -1621,10 +1620,10 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get(key)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key)).toBeDefined(); vi.advanceTimersByTime(90 * 1000); - expect(SessionTokenCache.get(key)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key)).toBeDefined(); vi.advanceTimersByTime(30 * 1000); expect(SessionTokenCache.get(key)).toBeUndefined(); @@ -1650,7 +1649,7 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ tokenId: label, tokenResolver }); await tokenResolver; - expect(SessionTokenCache.get({ tokenId: label })?.entry).toBeDefined(); + expect(SessionTokenCache.get({ tokenId: label })).toBeDefined(); vi.advanceTimersByTime(ttl * 1000); expect(SessionTokenCache.get({ tokenId: label })).toBeUndefined(); @@ -1680,7 +1679,7 @@ describe('SessionTokenCache', () => { const result = SessionTokenCache.get(keyWithAudience); expect(result).toBeDefined(); - expect(result?.entry.audience).toBe('https://api.example.com'); + expect(result?.tokenResolver).toBe(tokenResolver); }); it('treats tokens with different audiences as separate entries', async () => { @@ -1703,8 +1702,8 @@ describe('SessionTokenCache', () => { await Promise.all([resolver1, resolver2]); expect(SessionTokenCache.size()).toBe(2); - expect(SessionTokenCache.get(key1)?.entry).toBeDefined(); - expect(SessionTokenCache.get(key2)?.entry).toBeDefined(); + expect(SessionTokenCache.get(key1)).toBeDefined(); + expect(SessionTokenCache.get(key2)).toBeDefined(); }); }); @@ -1756,7 +1755,7 @@ describe('SessionTokenCache', () => { }); }); - describe('resolvedToken', () => { + describe('token', () => { it('is populated after tokenResolver resolves', async () => { const nowSeconds = Math.floor(Date.now() / 1000); const jwt = createJwtWithTtl(nowSeconds, 60); @@ -1772,42 +1771,17 @@ describe('SessionTokenCache', () => { SessionTokenCache.set({ ...key, tokenResolver }); - // Before promise resolves, resolvedToken should be undefined + // Before promise resolves, token should be undefined let result = SessionTokenCache.get(key); - expect(result?.entry.resolvedToken).toBeUndefined(); + expect(result?.token).toBeUndefined(); // Wait for promise to resolve await tokenResolver; - // After promise resolves, resolvedToken should be populated + // After promise resolves, token 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); + expect(result?.token).toBeDefined(); + expect(result?.token?.getRawString()).toBeTruthy(); }); }); @@ -1880,7 +1854,7 @@ describe('SessionTokenCache', () => { vi.advanceTimersByTime(31 * 1000); const result = SessionTokenCache.get(key); expect(result).toBeDefined(); - expect(result?.entry.tokenId).toBe('exp-overwrite'); + expect(result?.token).toBe(token2); }); it('simulates multiple refresh cycles without timer accumulation', async () => { @@ -2092,15 +2066,14 @@ describe('SessionTokenCache', () => { // (not session2's token) - tokens are isolated by tokenId const retrievedSession1Token = SessionTokenCache.get({ tokenId: session1Id }); expect(retrievedSession1Token).toBeDefined(); - const resolvedSession1Token = await retrievedSession1Token!.entry.tokenResolver; + const resolvedSession1Token = await retrievedSession1Token!.tokenResolver; expect(resolvedSession1Token.jwt?.claims?.iat).toBe(nowSeconds); - expect(retrievedSession1Token!.entry.tokenId).toBe(session1Id); + expect(retrievedSession1Token!.tokenResolver).toBe(session1Resolver); // Verify session2's token is separate const retrievedSession2Token = SessionTokenCache.get({ tokenId: session2Id }); expect(retrievedSession2Token).toBeDefined(); - expect(retrievedSession2Token!.entry.tokenId).toBe(session2Id); - expect(retrievedSession2Token!.entry.tokenId).not.toBe(session1Id); + expect(retrievedSession2Token!.tokenResolver).not.toBe(session1Resolver); }); it('accepts broadcast messages from the same session ID', async () => { @@ -2125,7 +2098,7 @@ describe('SessionTokenCache', () => { const cachedToken = SessionTokenCache.get({ tokenId: sessionId }); expect(cachedToken).toBeDefined(); - const resolvedToken = await cachedToken!.entry.tokenResolver; + const resolvedToken = await cachedToken!.tokenResolver; expect(resolvedToken.jwt?.claims?.iat).toBe(nowSeconds - 10); const newerJwt = createJwtWithTtl(nowSeconds, 60); @@ -2145,7 +2118,7 @@ describe('SessionTokenCache', () => { await vi.waitFor(async () => { const updatedCached = SessionTokenCache.get({ tokenId: sessionId }); expect(updatedCached).toBeDefined(); - const updatedToken = await updatedCached!.entry.tokenResolver; + const updatedToken = await updatedCached!.tokenResolver; expect(updatedToken.jwt?.claims?.iat).toBe(nowSeconds); }); @@ -2196,7 +2169,7 @@ describe('SessionTokenCache', () => { await tokenResolver; const result = SessionTokenCache.get(key); - expect(result?.entry.tokenId).toBe('no-bc-token'); + expect(result?.tokenResolver).toBe(tokenResolver); }); }); @@ -2211,8 +2184,8 @@ describe('SessionTokenCache', () => { await tokenResolver; // `audience || ''` collapses '' and undefined to the same key. - expect(SessionTokenCache.get({ tokenId: 'aud-coalesce' })?.entry.tokenId).toBe('aud-coalesce'); - expect(SessionTokenCache.get({ audience: '', tokenId: 'aud-coalesce' })?.entry.tokenId).toBe('aud-coalesce'); + expect(SessionTokenCache.get({ tokenId: 'aud-coalesce' })?.tokenResolver).toBe(tokenResolver); + expect(SessionTokenCache.get({ audience: '', tokenId: 'aud-coalesce' })?.tokenResolver).toBe(tokenResolver); expect(SessionTokenCache.size()).toBe(1); }); @@ -2230,8 +2203,8 @@ describe('SessionTokenCache', () => { await Promise.resolve(); expect(SessionTokenCache.size()).toBe(2); - expect(SessionTokenCache.get({ tokenId: 'aud-split' })?.entry).toBeDefined(); - expect(SessionTokenCache.get({ audience: 'https://api.example.com', tokenId: 'aud-split' })?.entry).toBeDefined(); + expect(SessionTokenCache.get({ tokenId: 'aud-split' })).toBeDefined(); + expect(SessionTokenCache.get({ audience: 'https://api.example.com', tokenId: 'aud-split' })).toBeDefined(); }); }); }); diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index 1a2c4023e65..b57ed02a8fc 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -506,7 +506,7 @@ export class Session extends BaseResource implements SessionResource { 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); + const cachedToken = cacheResult.token ?? (await cacheResult.tokenResolver); // Only emit token updates when we have an actual token — emitting with an empty // token causes AuthCookieService to remove the __session cookie (looks like sign-out). if (shouldDispatchTokenUpdate && cachedToken.getRawString()) { 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 b776ddd1753..d7378cda0b0 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -42,14 +42,17 @@ describe('Session', () => { describe('getToken()', () => { let dispatchSpy; + let cacheSetSpy; beforeEach(() => { dispatchSpy = vi.spyOn(eventBus, 'emit'); + cacheSetSpy = vi.spyOn(SessionTokenCache, 'set'); BaseResource.clerk = clerkMock(); }); afterEach(() => { dispatchSpy?.mockRestore(); + cacheSetSpy?.mockRestore(); BaseResource.clerk = null as any; }); @@ -74,7 +77,11 @@ describe('Session', () => { await session.getToken(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ + onRefresh: expect.any(Function), + tokenId, + tokenResolver: expect.any(Promise), + }); }); it('does not register proactive refresh after an unfocused network mint', async () => { @@ -84,7 +91,7 @@ describe('Session', () => { await session.getToken(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toBeUndefined(); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ tokenId, tokenResolver: expect.any(Promise) }); }); it('does not register proactive refresh for an unfocused hydrated token', async () => { @@ -94,7 +101,7 @@ describe('Session', () => { await Promise.resolve(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toBeUndefined(); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ tokenId, tokenResolver: expect.any(Promise) }); }); it('registers proactive refresh when document.hasFocus is not callable', async () => { @@ -104,7 +111,11 @@ describe('Session', () => { await session.getToken(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ + onRefresh: expect.any(Function), + tokenId, + tokenResolver: expect.any(Promise), + }); }); it('registers proactive refresh when document.hasFocus throws', async () => { @@ -116,7 +127,11 @@ describe('Session', () => { await session.getToken(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ + onRefresh: expect.any(Function), + tokenId, + tokenResolver: expect.any(Promise), + }); }); it('registers proactive refresh when document is missing', async () => { @@ -126,7 +141,11 @@ describe('Session', () => { await session.getToken(); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.onRefresh).toEqual(expect.any(Function)); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ + onRefresh: expect.any(Function), + tokenId, + tokenResolver: expect.any(Promise), + }); }); }); @@ -849,9 +868,11 @@ describe('Session', () => { const freshToken = await session.getToken(); expect(freshToken).toEqual(newMockJwt); expect(requestSpy).not.toHaveBeenCalled(); - expect( - SessionTokenCache.get({ tokenId: TokenId.build('session_1', undefined, null) })?.entry.onRefresh, - ).toEqual(expect.any(Function)); + expect(cacheSetSpy).toHaveBeenLastCalledWith({ + onRefresh: expect.any(Function), + tokenId: TokenId.build('session_1', undefined, null), + tokenResolver: expect.any(Promise), + }); }); it('does not emit token:update with an empty token when background refresh fires while offline', async () => { @@ -2622,7 +2643,7 @@ describe('Session', () => { SessionTokenCache.clear(); }); - it('concurrent skipCache mints return their own token; cache slot and lastActiveToken stay freshest (resolve high then low)', async () => { + it('concurrent skipCache mints return their own token; cache entry and lastActiveToken stay freshest (resolve high then low)', async () => { const session = makeSession(); const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2638,17 +2659,17 @@ describe('Session', () => { await expect(pHigh).resolves.toBe(high); // Each skipCache call returns its own mint (main parity): the low fetch resolves last and - // returns low, but it never regresses the cache slot or lastActiveToken. + // returns low, but it never regresses the cache entry or lastActiveToken. dLow.resolve({ object: 'token', jwt: low }); await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(high); expect(await session.getToken()).toBe(high); expect(session.lastActiveToken?.getRawString()).toBe(high); }); - it('concurrent skipCache mints return their own token; cache slot and lastActiveToken stay freshest (resolve low then high)', async () => { + it('concurrent skipCache mints return their own token; cache entry and lastActiveToken stay freshest (resolve low then high)', async () => { const session = makeSession(); const high = createJwtWithOiat(NOW, NOW + 30); const low = createJwtWithOiat(NOW, NOW); @@ -2668,7 +2689,7 @@ describe('Session', () => { await expect(pSecond).resolves.toBe(high); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(high); expect(await session.getToken()).toBe(high); expect(session.lastActiveToken?.getRawString()).toBe(high); }); @@ -2694,7 +2715,7 @@ describe('Session', () => { expect(fetchSpy.mock.calls[2][0].body.token).toBe(high); const tokenId = TokenId.build('session_1', undefined, null); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(high); expect(session.lastActiveToken?.getRawString()).toBe(high); }); @@ -2710,14 +2731,14 @@ describe('Session', () => { const pHigh = session.getToken({ organizationId: 'org_other', skipCache: true }); const pLow = session.getToken({ organizationId: 'org_other', skipCache: true }); - // Each skipCache call returns its own mint (main parity); the cache slot still stays freshest. + // Each skipCache call returns its own mint (main parity); the cache entry still stays freshest. dHigh.resolve({ object: 'token', jwt: high }); await expect(pHigh).resolves.toBe(high); dLow.resolve({ object: 'token', jwt: low }); await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', undefined, 'org_other'); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(high); expect(session.lastActiveToken).toBeNull(); }); @@ -2733,14 +2754,14 @@ describe('Session', () => { const pHigh = session.getToken({ template: 't', skipCache: true }); const pLow = session.getToken({ template: 't', skipCache: true }); - // Each skipCache call returns its own mint (main parity); the cache slot still stays freshest. + // Each skipCache call returns its own mint (main parity); the cache entry still stays freshest. dHigh.resolve({ object: 'token', jwt: high }); await expect(pHigh).resolves.toBe(high); dLow.resolve({ object: 'token', jwt: low }); await expect(pLow).resolves.toBe(low); const tokenId = TokenId.build('session_1', 't', null); - expect(SessionTokenCache.get({ tokenId })?.entry.resolvedToken?.getRawString()).toBe(high); + expect(SessionTokenCache.get({ tokenId })?.token?.getRawString()).toBe(high); expect(session.lastActiveToken).toBeNull(); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index d5edee93df3..154e23b674a 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -10,21 +10,15 @@ import { pickFreshestJwt } from './tokenFreshness'; import { createTokenStore } from './tokenStore'; /** - * Cache entry containing token metadata and resolver. - * Extends TokenCacheKeyJSON with additional properties for expiration tracking and token retrieval. + * Parameters for set(): the cache key, the token resolver, and an optional refresh callback. */ -interface TokenCacheEntry extends TokenCacheKeyJSON { +interface TokenCacheSetParams extends TokenCacheKeyJSON { /** * 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. @@ -40,21 +34,22 @@ interface TokenWithExpiry { } /** - * Internal cache value containing the entry, expiration metadata, and timers. + * Internal per-key cache entry containing the latest set() params, the freshest known token, and timers. */ -interface TokenCacheValue { +interface TokenCacheEntry { /** * Freshest claims-valid token observed for this key, with its expiry fixed when * the cache first saw it for as long as the cache holds it, chained across set() * calls and updated by every resolver settle, including resolvers replaced by a - * newer set() while still pending. Internal bookkeeping only: readers only ever - * see entry.resolvedToken, so a pending entry still reads as pending and callers - * await its resolver. Folded into resolvedToken when the live entry resolves, - * which is what keeps a staler resolve from overwriting a fresher token. + * newer set() while still pending. Readers only see it once the entry stops being + * pending, so a pending entry still reads as pending and callers await its + * resolver. Comparing the entry's own resolve against it is what keeps a staler + * resolve from overwriting a fresher token. */ freshestKnown?: TokenWithExpiry; - cachedAt: Seconds; - entry: TokenCacheEntry; + createdAt: Seconds; + isPending: boolean; + params: TokenCacheSetParams; /** Timer for automatic cache cleanup when token expires */ timeoutId?: ReturnType; /** Timer for proactive refresh before token enters leeway period */ @@ -62,10 +57,19 @@ interface TokenCacheValue { } /** - * Result from cache lookup containing the entry. + * Result from cache lookup containing the token, or its resolver while the entry is pending. */ -export interface TokenCacheGetResult { - entry: TokenCacheEntry; +export interface TokenCacheHit { + /** + * The resolved token value for synchronous reads. + * Populated after tokenResolver resolves. Check this first to avoid microtask overhead. + */ + token?: TokenResource; + /** + * Promise that resolves to the TokenResource. + * May be pending and should be awaited when token is not populated. + */ + tokenResolver: Promise; } export interface TokenCache { @@ -87,17 +91,17 @@ export interface TokenCache { * Proactive refresh is handled by timers scheduled when tokens are cached. * * @param cacheKeyJSON - Object containing tokenId and optional audience to identify the cached entry - * @returns Result with entry, or undefined if token is missing/expired/too close to expiration + * @returns Result with the token or its resolver, or undefined if token is missing/expired/too close to expiration */ - get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined; + get(cacheKeyJSON: TokenCacheKeyJSON): TokenCacheHit | undefined; /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. * - * @param entry - TokenCacheEntry containing tokenId, tokenResolver, and optional audience + * @param params - TokenCacheSetParams containing tokenId, tokenResolver, and optional audience * Side effects: Schedules automatic expiration cleanup, broadcasts to other tabs when token resolves */ - set(entry: TokenCacheEntry): void; + set(params: TokenCacheSetParams): void; /** * Returns the current number of cached entries. @@ -153,7 +157,7 @@ const pickFreshest = (existing: TokenWithExpiry | undefined, incoming: TokenWith * BroadcastChannel support is enabled whenever the environment provides it. */ const MemoryTokenCache = (prefix?: string): TokenCache => { - const store = createTokenStore(); + const store = createTokenStore(); const keyResolver = createKeyResolver(prefix); const tabId = generateTabId(); @@ -179,46 +183,45 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { ensureBroadcastChannel(); const clear = () => { - store.forEach(value => { - if (value.timeoutId !== undefined) { - clearTimeout(value.timeoutId); + store.forEach(entry => { + if (entry.timeoutId !== undefined) { + clearTimeout(entry.timeoutId); } - if (value.refreshTimeoutId !== undefined) { - clearTimeout(value.refreshTimeoutId); + if (entry.refreshTimeoutId !== undefined) { + clearTimeout(entry.refreshTimeoutId); } }); store.clear(); }; - const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => { + const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheHit | undefined => { ensureBroadcastChannel(); const key = keyResolver.toKey(cacheKeyJSON); - const value = store.get(key); + const entry = store.get(key); - if (!value) { + if (!entry) { return; } - const hasResolvedToken = value.entry.resolvedToken !== undefined; - const expiresAt = hasResolvedToken && value.freshestKnown ? value.freshestKnown.expiresAt : Infinity; - const remainingTtl = expiresAt - Math.floor(Date.now() / 1000); + const served = entry.isPending ? undefined : entry.freshestKnown; + const remainingTtl = (served?.expiresAt ?? Infinity) - Math.floor(Date.now() / 1000); // 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 (entry.timeoutId !== undefined) { + clearTimeout(entry.timeoutId); } - if (value.refreshTimeoutId !== undefined) { - clearTimeout(value.refreshTimeoutId); + if (entry.refreshTimeoutId !== undefined) { + clearTimeout(entry.refreshTimeoutId); } store.delete(key); return; } // Proactive refresh is handled by timers scheduled in setInternal() - return { entry: value.entry }; + return { token: served?.token, tokenResolver: entry.params.tokenResolver }; }; /** @@ -269,7 +272,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { try { const result = get({ tokenId: data.tokenId }); if (result) { - const existingToken = result.entry.resolvedToken ?? (await result.entry.tokenResolver); + const existingToken = result.token ?? (await result.tokenResolver); if (pickFreshestJwt(existingToken, token) === existingToken) { debugLogger.debug( 'Ignoring staler token broadcast', @@ -309,41 +312,41 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { ); }; - const set = (entry: TokenCacheEntry) => { + const set = (params: TokenCacheSetParams) => { ensureBroadcastChannel(); - setInternal(entry, BROADCAST); + setInternal(params, BROADCAST); }; /** * Internal cache setter that stores an entry and schedules expiration cleanup. * Resolves the token promise to extract expiration claims and set a deletion timeout. * - * @param entry - The token cache entry to store + * @param params - The set() params to store * @param options - Configuration for cache behavior; broadcast controls whether to notify other tabs */ - const setInternal = (entry: TokenCacheEntry, options: { broadcast: boolean } = BROADCAST) => { + const setInternal = (params: TokenCacheSetParams, options: { broadcast: boolean } = BROADCAST) => { const key = keyResolver.toKey({ - audience: entry.audience, - tokenId: entry.tokenId, + audience: params.audience, + tokenId: params.tokenId, }); // Clear timers from any existing entry for this key to prevent orphaned // refresh timers from accumulating across set() calls (e.g., from // #hydrateCache during _updateClient AND #refreshTokenInBackground). - const existing = store.get(key); - clearTimeout(existing?.timeoutId); - clearTimeout(existing?.refreshTimeoutId); + const previousEntry = store.get(key); + clearTimeout(previousEntry?.timeoutId); + clearTimeout(previousEntry?.refreshTimeoutId); - const value: TokenCacheValue = { cachedAt: Math.floor(Date.now() / 1000), entry }; + const ownEntry: TokenCacheEntry = { createdAt: Math.floor(Date.now() / 1000), isPending: true, params }; - // Chain the freshest known token across replacements. This never touches - // entry.resolvedToken: a pending entry reads as pending and callers await. - value.freshestKnown = existing?.freshestKnown; + // Chain the freshest known token across replacements. The new entry still + // starts pending: a pending entry reads as pending and callers await. + ownEntry.freshestKnown = previousEntry?.freshestKnown; - // Clears both timers and drops the slot, but only if it still holds `target` + // Clears both timers and drops `target`, but only if the key still holds it // (a newer set() may have replaced it while a promise/timer was pending). - const dropIfCurrent = (target: TokenCacheValue) => { + const dropIfCurrent = (target: TokenCacheEntry) => { if (store.get(key) !== target) { return; } @@ -352,25 +355,25 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { store.delete(key); }; - store.set(key, value); + store.set(key, ownEntry); - entry.tokenResolver + params.tokenResolver .then(newToken => { - const live = store.get(key); - if (!live) { + const liveEntry = store.get(key); + if (!liveEntry) { // Cleared while pending; do not resurrect. return; } const claims = newToken.jwt?.claims; const isValid = !!claims && typeof claims.exp === 'number' && typeof claims.iat === 'number'; - const isOwn = live === value; + const isOwn = liveEntry === ownEntry; if (isOwn && !isValid) { - // The live slot's own fetch resolved unusable: drop the slot so the next + // The live entry's own fetch resolved unusable: drop the entry so the next // read refetches. Keeping the freshest token alive here would hide a broken // token endpoint behind cache hits. - dropIfCurrent(live); + dropIfCurrent(liveEntry); return; } if (!isValid) { @@ -381,42 +384,41 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { // device's lag. A full lifetime from cache insert is late by the token's age // on arrival. The earlier of the two is late only by the smaller error. const candidate: TokenWithExpiry = { - expiresAt: Math.min(claims.exp, value.cachedAt + claims.exp - claims.iat), + expiresAt: Math.min(claims.exp, ownEntry.createdAt + claims.exp - claims.iat), token: newToken, }; // Track the freshest token seen for this key, even when this resolver was // replaced by a newer set() while it was pending. - const previous = live.freshestKnown; + const previous = liveEntry.freshestKnown; const winner = pickFreshest(previous, candidate); - live.freshestKnown = winner; + liveEntry.freshestKnown = winner; - // resolvedToken is only written once the live slot itself resolves. While - // the live slot is pending, readers must keep awaiting its resolver, so a + // The token is only served once the live entry itself resolves. While + // the live entry is pending, readers must keep awaiting its resolver, so a // replaced resolver may only advance the freshest token above. - const hasResolvedToken = live.entry.resolvedToken !== undefined; - if (!isOwn && !hasResolvedToken) { + if (!isOwn && liveEntry.isPending) { return; } - if (hasResolvedToken && winner === previous) { + if (!liveEntry.isPending && winner === previous) { // Nothing advanced; the installed timers already match the winner. return; } - live.entry.resolvedToken = winner.token; + liveEntry.isPending = false; - clearTimeout(live.timeoutId); - clearTimeout(live.refreshTimeoutId); + clearTimeout(liveEntry.timeoutId); + clearTimeout(liveEntry.refreshTimeoutId); const remainingTtl: Seconds = winner.expiresAt - Math.floor(Date.now() / 1000); if (remainingTtl <= 0) { - dropIfCurrent(live); + dropIfCurrent(liveEntry); return; } - const timeoutId = setTimeout(() => dropIfCurrent(live), remainingTtl * 1000); - live.timeoutId = timeoutId; + const timeoutId = setTimeout(() => dropIfCurrent(liveEntry), remainingTtl * 1000); + liveEntry.timeoutId = timeoutId; // Teach ClerkJS not to block the exit of the event loop when used in Node environments. // More info at https://nodejs.org/api/timers.html#timeoutunref @@ -433,12 +435,12 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, minLeeway); const refreshFireTime = remainingTtl - leeway - refreshLeadTime; - if (refreshFireTime > 0 && live.entry.onRefresh) { + if (refreshFireTime > 0 && liveEntry.params.onRefresh) { const refreshTimeoutId = setTimeout(() => { - live.entry.onRefresh?.(); + liveEntry.params.onRefresh?.(); }, refreshFireTime * 1000); - live.refreshTimeoutId = refreshTimeoutId; + liveEntry.refreshTimeoutId = refreshTimeoutId; if (typeof (refreshTimeoutId as any).unref === 'function') { (refreshTimeoutId as any).unref(); @@ -455,10 +457,10 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { if (tokenRaw && winnerClaims?.sid) { const sessionId = winnerClaims.sid; const organizationId = winnerClaims.org_id || (winnerClaims.o as any)?.id; - const template = TokenId.extractTemplate(entry.tokenId, sessionId, organizationId); + const template = TokenId.extractTemplate(params.tokenId, sessionId, organizationId); const expectedTokenId = TokenId.build(sessionId, template, organizationId); - if (entry.tokenId === expectedTokenId) { + if (params.tokenId === expectedTokenId) { const traceId = `bc_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; debugLogger.info( @@ -468,7 +470,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { sessionId, tabId, template, - tokenId: entry.tokenId, + tokenId: params.tokenId, traceId, }, 'tokenCache', @@ -478,7 +480,7 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { organizationId, sessionId, template, - tokenId: entry.tokenId, + tokenId: params.tokenId, tokenRaw, traceId, }; @@ -489,14 +491,14 @@ const MemoryTokenCache = (prefix?: string): TokenCache => { } catch (error) { debugLogger.warn( 'Failed to broadcast token update to other tabs', - { error, tabId, tokenId: entry.tokenId }, + { error, tabId, tokenId: params.tokenId }, 'tokenCache', ); } } }) .catch(() => { - dropIfCurrent(value); + dropIfCurrent(ownEntry); }); };