Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files": [
{ "path": "./dist/clerk.js", "maxSize": "549KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "74KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" },
{ "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" },
{ "path": "./dist/clerk.native.js", "maxSize": "73KB" },
{ "path": "./dist/vendors*.js", "maxSize": "7KB" },
Expand Down
82 changes: 82 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1622,4 +1622,86 @@ describe('SessionTokenCache', () => {
expect(SessionTokenCache.size()).toBe(1);
});
});

// --- SDK-117 characterization backfill ---------------------------------
// These lock in current, intended behavior before the cache is split into
// separate storage / scheduler / cross-tab collaborators. They are the
// regression bar for that refactor, covering the gaps the audit surfaced:
// BroadcastChannel lifecycle, broadcast-failure resilience, graceful
// degradation without BroadcastChannel, and audience key coalescing.

describe('BroadcastChannel lifecycle', () => {
it('close() closes the underlying channel', () => {
expect(mockBroadcastChannel.close).not.toHaveBeenCalled();

SessionTokenCache.close();

expect(mockBroadcastChannel.close).toHaveBeenCalledTimes(1);
});

it('lazily reopens a new channel on the next operation after close()', () => {
SessionTokenCache.close();
(global.BroadcastChannel as unknown as ReturnType<typeof vi.fn>).mockClear();

// get() calls ensureBroadcastChannel(), which must reconstruct the channel
SessionTokenCache.get({ tokenId: 'anything' });

expect(global.BroadcastChannel).toHaveBeenCalledTimes(1);
});
});

describe('graceful degradation without BroadcastChannel', () => {
it('continues to cache and retrieve tokens when BroadcastChannel is unavailable', async () => {
// Simulate a runtime that does not provide BroadcastChannel.
SessionTokenCache.close();
(global as any).BroadcastChannel = undefined;

const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);
const token = new Token({ id: 'no-bc-token', jwt, object: 'token' });
const tokenResolver = Promise.resolve<TokenResource>(token);
const key = { tokenId: 'no-bc-token' };

expect(() => SessionTokenCache.set({ ...key, tokenResolver })).not.toThrow();
await tokenResolver;

const result = SessionTokenCache.get(key);
expect(result?.entry.tokenId).toBe('no-bc-token');
});
});

describe('audience key coalescing', () => {
it('treats empty-string audience and undefined audience as the same entry', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const jwt = createJwtWithTtl(nowSeconds, 60);
const token = new Token({ id: 'aud-coalesce', jwt, object: 'token' });
const tokenResolver = Promise.resolve<TokenResource>(token);

SessionTokenCache.set({ audience: '', tokenId: 'aud-coalesce', tokenResolver });
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.size()).toBe(1);
});

it('isolates an audience-scoped token from the no-audience token of the same id', async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const tokenA = new Token({ id: 'aud-split', jwt: createJwtWithTtl(nowSeconds, 60), object: 'token' });
const tokenB = new Token({ id: 'aud-split', jwt: createJwtWithTtl(nowSeconds, 60), object: 'token' });

SessionTokenCache.set({ tokenId: 'aud-split', tokenResolver: Promise.resolve<TokenResource>(tokenA) });
SessionTokenCache.set({
audience: 'https://api.example.com',
tokenId: 'aud-split',
tokenResolver: Promise.resolve<TokenResource>(tokenB),
});
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();
});
});
});
75 changes: 75 additions & 0 deletions packages/clerk-js/src/core/__tests__/tokenStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';

import { createTokenStore } from '../tokenStore';

describe('createTokenStore', () => {
it('stores and retrieves values by key', () => {
const store = createTokenStore<number>();
store.set('a', 1);
expect(store.get('a')).toBe(1);
});

it('returns undefined for a missing key', () => {
const store = createTokenStore<number>();
expect(store.get('missing')).toBeUndefined();
});

it('overwrites an existing key', () => {
const store = createTokenStore<string>();
store.set('k', 'first');
store.set('k', 'second');
expect(store.get('k')).toBe('second');
expect(store.size()).toBe(1);
});

it('deletes a key', () => {
const store = createTokenStore<number>();
store.set('a', 1);
store.delete('a');
expect(store.get('a')).toBeUndefined();
expect(store.size()).toBe(0);
});

it('treats delete on a missing key as a no-op', () => {
const store = createTokenStore<number>();
expect(() => store.delete('nope')).not.toThrow();
expect(store.size()).toBe(0);
});

it('clears all entries', () => {
const store = createTokenStore<number>();
store.set('a', 1);
store.set('b', 2);
store.clear();
expect(store.size()).toBe(0);
expect(store.get('a')).toBeUndefined();
});

it('reports the number of entries', () => {
const store = createTokenStore<number>();
expect(store.size()).toBe(0);
store.set('a', 1);
store.set('b', 2);
expect(store.size()).toBe(2);
});

it('iterates every entry with forEach', () => {
const store = createTokenStore<number>();
store.set('a', 1);
store.set('b', 2);

const seen = vi.fn();
store.forEach(seen);

expect(seen).toHaveBeenCalledTimes(2);
expect(seen).toHaveBeenCalledWith(1, 'a');
expect(seen).toHaveBeenCalledWith(2, 'b');
});

it('keeps reference identity for object values behind one generic interface', () => {
const store = createTokenStore<{ raw: string }>();
const value = { raw: 'token' };
store.set('x', value);
expect(store.get('x')).toBe(value);
});
});
23 changes: 12 additions & 11 deletions packages/clerk-js/src/core/tokenCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TokenId } from '@/utils/tokenId';
import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller';
import { Token } from './resources/internal';
import { pickFreshestJwt } from './tokenFreshness';
import { createTokenStore } from './tokenStore';

/**
* Identifies a cached token entry by tokenId and optional audience.
Expand Down Expand Up @@ -173,7 +174,7 @@ const generateTabId = (): string => {
* BroadcastChannel support is enabled whenever the environment provides it.
*/
const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
const cache = new Map<string, TokenCacheValue>();
const store = createTokenStore<TokenCacheValue>();
const tabId = generateTabId();

let broadcastChannel: BroadcastChannel | null = null;
Expand All @@ -198,22 +199,22 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
ensureBroadcastChannel();

const clear = () => {
cache.forEach(value => {
store.forEach(value => {
if (value.timeoutId !== undefined) {
clearTimeout(value.timeoutId);
}
if (value.refreshTimeoutId !== undefined) {
clearTimeout(value.refreshTimeoutId);
}
});
cache.clear();
store.clear();
};

const get = (cacheKeyJSON: TokenCacheKeyJSON): TokenCacheGetResult | undefined => {
ensureBroadcastChannel();

const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON);
const value = cache.get(cacheKey.toKey());
const value = store.get(cacheKey.toKey());

if (!value) {
return;
Expand All @@ -232,7 +233,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
if (value.refreshTimeoutId !== undefined) {
clearTimeout(value.refreshTimeoutId);
}
cache.delete(cacheKey.toKey());
store.delete(cacheKey.toKey());
return;
}

Expand Down Expand Up @@ -353,7 +354,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
// 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 = cache.get(key);
const existing = store.get(key);
clearTimeout(existing?.timeoutId);
clearTimeout(existing?.refreshTimeoutId);

Expand All @@ -362,27 +363,27 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined };

const deleteKey = () => {
const cachedValue = cache.get(key);
const cachedValue = store.get(key);
if (cachedValue === value) {
if (cachedValue.timeoutId !== undefined) {
clearTimeout(cachedValue.timeoutId);
}
if (cachedValue.refreshTimeoutId !== undefined) {
clearTimeout(cachedValue.refreshTimeoutId);
}
cache.delete(key);
store.delete(key);
}
};

cache.set(key, value);
store.set(key, value);

entry.tokenResolver
.then(newToken => {
// If this entry was overwritten by a newer set() call while our promise
// was pending, bail out to avoid installing orphaned timers. Monotonic
// replacement is enforced at the read sites (cookie + broadcast + Session)
// where the user-visible state lives.
if (cache.get(key) !== value) {
if (store.get(key) !== value) {
return;
}

Expand Down Expand Up @@ -493,7 +494,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => {
};

const size = () => {
return cache.size;
return store.size();
};

return { clear, close, get, set, size };
Expand Down
45 changes: 45 additions & 0 deletions packages/clerk-js/src/core/tokenStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Generic in-memory key/value store backing the token cache.
*
* Pure storage: no timers, no BroadcastChannel, and no JWT knowledge. The cache
* layers proactive-refresh scheduling and cross-tab synchronization on top.
* Synchronous by design — the in-memory path never needs to be async — modelled
* on auth0-spa-js's synchronous cache interface.
*/
export interface TokenStore<V> {
get(key: string): V | undefined;
set(key: string, value: V): void;
delete(key: string): void;
clear(): void;
/**
* Iterates over every stored entry. Used by the cache to release per-entry
* timers before clearing.
*/
forEach(callback: (value: V, key: string) => void): void;
size(): number;
}

/**
* Creates an empty in-memory {@link TokenStore} backed by a Map.
*/
export const createTokenStore = <V>(): TokenStore<V> => {
const map = new Map<string, V>();

return {
get: key => map.get(key),
set: (key, value) => {
map.set(key, value);
},
delete: key => {
map.delete(key);
},
clear: () => {
map.clear();
},
forEach: callback => {
// Wrap so the underlying Map reference is not leaked as a third argument.
map.forEach((value, key) => callback(value, key));
},
size: () => map.size,
};
};
Loading