Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(js): extract a generic store from TokenCache by jacekradko · Pull Request #8860 · clerk/javascript · GitHub
Skip to content
2 changes: 2 additions & 0 deletions .changeset/decouple-tokencache-store.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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