Skip to content

Commit 46fcf73

Browse files
committed
refactor(js): extract the refresh scheduler from TokenCache
1 parent c5697d7 commit 46fcf73

5 files changed

Lines changed: 331 additions & 74 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
Compute the proactive session-token refresh from the token's absolute expiry. A token restored from the session cookie on page load (issued before the tab opened) now schedules its background refresh ahead of expiry instead of after it, where previously the refresh could be scheduled past expiration and never fire proactively.
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { type Clock, createRefreshScheduler } from '../refreshScheduler';
4+
5+
// leeway = max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS=15, POLLER_INTERVAL/1000=5) = 15
6+
// refresh lead time = 2, so a token fires its refresh at expiresAt - now - 17.
7+
const NOW = 1000;
8+
9+
describe('createRefreshScheduler', () => {
10+
let now: number;
11+
const clock: Clock = { now: () => now };
12+
13+
beforeEach(() => {
14+
now = NOW;
15+
vi.useFakeTimers();
16+
});
17+
18+
afterEach(() => {
19+
vi.useRealTimers();
20+
});
21+
22+
it('fires onRefresh at expiresAt - now - 17 (43s for a fresh 60s token)', () => {
23+
const scheduler = createRefreshScheduler(clock);
24+
const onRefresh = vi.fn();
25+
scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh });
26+
27+
vi.advanceTimersByTime(42 * 1000);
28+
expect(onRefresh).not.toHaveBeenCalled();
29+
vi.advanceTimersByTime(2 * 1000);
30+
expect(onRefresh).toHaveBeenCalledTimes(1);
31+
});
32+
33+
it('fires onExpire at expiresAt - now', () => {
34+
const scheduler = createRefreshScheduler(clock);
35+
const onExpire = vi.fn();
36+
scheduler.schedule('k', { expiresAt: now + 60, onExpire, onRefresh: vi.fn() });
37+
38+
vi.advanceTimersByTime(59 * 1000);
39+
expect(onExpire).not.toHaveBeenCalled();
40+
vi.advanceTimersByTime(1 * 1000);
41+
expect(onExpire).toHaveBeenCalledTimes(1);
42+
});
43+
44+
it('recomputes the refresh against the wall clock for a past-issuance token', () => {
45+
// Token minted 30s ago with a 60s TTL: exp is only 30s in the future, so the
46+
// refresh must fire at 30 - 17 = 13s, not at a fixed ttl - 17 = 43s.
47+
const scheduler = createRefreshScheduler(clock);
48+
const onRefresh = vi.fn();
49+
scheduler.schedule('k', { expiresAt: now + 30, onExpire: vi.fn(), onRefresh });
50+
51+
vi.advanceTimersByTime(12 * 1000);
52+
expect(onRefresh).not.toHaveBeenCalled();
53+
vi.advanceTimersByTime(1 * 1000);
54+
expect(onRefresh).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('does not arm a refresh timer when the refresh point is already in the past', () => {
58+
const scheduler = createRefreshScheduler(clock);
59+
const onRefresh = vi.fn();
60+
const onExpire = vi.fn();
61+
// 10s TTL: refresh point is 10 - 17 = -7 < 0, so only the expiration timer arms.
62+
scheduler.schedule('k', { expiresAt: now + 10, onExpire, onRefresh });
63+
64+
vi.advanceTimersByTime(60 * 1000);
65+
expect(onRefresh).not.toHaveBeenCalled();
66+
expect(onExpire).toHaveBeenCalledTimes(1);
67+
});
68+
69+
it('does not arm an expiration timer when the token is already expired', () => {
70+
const scheduler = createRefreshScheduler(clock);
71+
const onExpire = vi.fn();
72+
const onRefresh = vi.fn();
73+
scheduler.schedule('k', { expiresAt: now - 5, onExpire, onRefresh });
74+
75+
vi.advanceTimersByTime(60 * 1000);
76+
expect(onExpire).not.toHaveBeenCalled();
77+
expect(onRefresh).not.toHaveBeenCalled();
78+
});
79+
80+
it('does not arm a refresh timer when onRefresh is omitted', () => {
81+
const scheduler = createRefreshScheduler(clock);
82+
const onExpire = vi.fn();
83+
scheduler.schedule('k', { expiresAt: now + 60, onExpire });
84+
85+
// Only the expiration timer should fire; nothing throws from a missing onRefresh.
86+
vi.advanceTimersByTime(60 * 1000);
87+
expect(onExpire).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it('cancel() disarms both timers for a key before they fire', () => {
91+
const scheduler = createRefreshScheduler(clock);
92+
const onExpire = vi.fn();
93+
const onRefresh = vi.fn();
94+
scheduler.schedule('k', { expiresAt: now + 60, onExpire, onRefresh });
95+
96+
scheduler.cancel('k');
97+
vi.advanceTimersByTime(120 * 1000);
98+
expect(onExpire).not.toHaveBeenCalled();
99+
expect(onRefresh).not.toHaveBeenCalled();
100+
});
101+
102+
it('cancel() of an unknown key is a no-op', () => {
103+
const scheduler = createRefreshScheduler(clock);
104+
expect(() => scheduler.cancel('missing')).not.toThrow();
105+
});
106+
107+
it('cancelAll() disarms every key', () => {
108+
const scheduler = createRefreshScheduler(clock);
109+
const a = vi.fn();
110+
const b = vi.fn();
111+
scheduler.schedule('a', { expiresAt: now + 60, onExpire: a, onRefresh: a });
112+
scheduler.schedule('b', { expiresAt: now + 60, onExpire: b, onRefresh: b });
113+
114+
scheduler.cancelAll();
115+
vi.advanceTimersByTime(120 * 1000);
116+
expect(a).not.toHaveBeenCalled();
117+
expect(b).not.toHaveBeenCalled();
118+
});
119+
120+
it('re-scheduling a key cancels the prior timers (no accumulation)', () => {
121+
const scheduler = createRefreshScheduler(clock);
122+
const first = vi.fn();
123+
const second = vi.fn();
124+
scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: first });
125+
scheduler.schedule('k', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: second });
126+
127+
vi.advanceTimersByTime(60 * 1000);
128+
expect(first).not.toHaveBeenCalled();
129+
expect(second).toHaveBeenCalledTimes(1);
130+
});
131+
132+
it('cancelling one key leaves another key armed', () => {
133+
const scheduler = createRefreshScheduler(clock);
134+
const a = vi.fn();
135+
const b = vi.fn();
136+
scheduler.schedule('a', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: a });
137+
scheduler.schedule('b', { expiresAt: now + 60, onExpire: vi.fn(), onRefresh: b });
138+
139+
scheduler.cancel('a');
140+
vi.advanceTimersByTime(60 * 1000);
141+
expect(a).not.toHaveBeenCalled();
142+
expect(b).toHaveBeenCalledTimes(1);
143+
});
144+
});

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,44 @@ describe('SessionTokenCache', () => {
13291329
expect(backgroundRefresh).toHaveBeenCalledTimes(1);
13301330
});
13311331

1332+
it('a stale rejected resolver after overwrite does not cancel the replacement timers', async () => {
1333+
const nowSeconds = Math.floor(Date.now() / 1000);
1334+
const jwt = createJwtWithTtl(nowSeconds, 60);
1335+
const newToken = new Token({ id: 'stale-reject', jwt, object: 'token' });
1336+
1337+
const key = { tokenId: 'stale-reject' };
1338+
const staleRefresh = vi.fn();
1339+
const newRefresh = vi.fn();
1340+
1341+
// 1. First set() with a still-pending resolver that will later reject.
1342+
let rejectStale: (reason?: unknown) => void = () => {};
1343+
const staleResolver = new Promise<TokenResource>((_resolve, reject) => {
1344+
rejectStale = reject;
1345+
});
1346+
SessionTokenCache.set({ ...key, tokenResolver: staleResolver, onRefresh: staleRefresh });
1347+
1348+
// 2. Overwrite with a resolved token; its refresh timer arms at 43s.
1349+
SessionTokenCache.set({
1350+
...key,
1351+
tokenResolver: Promise.resolve<TokenResource>(newToken),
1352+
onRefresh: newRefresh,
1353+
});
1354+
await Promise.resolve();
1355+
1356+
// 3. The stale resolver rejects AFTER the overwrite. Its .catch(deleteKey) must bail on the
1357+
// identity guard — it must not cancel the replacement's live timers nor evict its token.
1358+
rejectStale(new Error('stale token fetch failed'));
1359+
for (let i = 0; i < 5; i++) {
1360+
await Promise.resolve();
1361+
}
1362+
1363+
expect(SessionTokenCache.get(key)?.entry.tokenId).toBe('stale-reject');
1364+
1365+
vi.advanceTimersByTime(44 * 1000);
1366+
expect(staleRefresh).not.toHaveBeenCalled();
1367+
expect(newRefresh).toHaveBeenCalledTimes(1);
1368+
});
1369+
13321370
it('cancels old expiration timer when set() is called again for the same key', async () => {
13331371
const nowSeconds = Math.floor(Date.now() / 1000);
13341372
const jwt1 = createJwtWithTtl(nowSeconds, 30);
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Owns the per-token timers for the session token cache: an expiration-cleanup
3+
* timer and a proactive background-refresh timer. Keeping the scheduling out of
4+
* the storage layer lets the cache deal in opaque keys and makes timer behavior
5+
* independently testable through an injected clock.
6+
*
7+
* Timers are still backed by `setTimeout`; the injected clock only supplies
8+
* `now()` so the fire times can be recomputed against the wall clock (a token
9+
* issued before the tab loaded refreshes at its true expiry, not relative to
10+
* when it was cached).
11+
*/
12+
13+
import { POLLER_INTERVAL_IN_MS } from './auth/SessionCookiePoller';
14+
15+
/**
16+
* Seconds before token expiration to trigger a proactive background refresh.
17+
* Sized to absorb timer jitter, SafeLock contention (~5s), and network latency.
18+
*/
19+
const BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS = 15;
20+
21+
/**
22+
* Seconds of buffer before the leeway window so a refresh completes before the
23+
* old token enters leeway. Token fetches typically finish in ~100ms; 2s is ample.
24+
*/
25+
const REFRESH_LEAD_TIME_IN_SECONDS = 2;
26+
27+
/**
28+
* Source of the current time, in seconds since the UNIX epoch. Injected so timer
29+
* fire points are deterministic in tests; defaults to the wall clock.
30+
*/
31+
export interface Clock {
32+
now(): number;
33+
}
34+
35+
interface ScheduleParams {
36+
/** Absolute expiry of the token, in seconds since the UNIX epoch (JWT `exp`). */
37+
expiresAt: number;
38+
/** Invoked when the expiration-cleanup timer fires. */
39+
onExpire: () => void;
40+
/** Invoked when the proactive-refresh timer fires. Omit to skip the refresh timer. */
41+
onRefresh?: () => void;
42+
}
43+
44+
export interface RefreshScheduler {
45+
/**
46+
* Arms the expiration and proactive-refresh timers for a key, cancelling any
47+
* prior timers for that key first. Delays are recomputed against the clock, so
48+
* an already-expired or past-issuance token arms only the timers that are still
49+
* in the future.
50+
*/
51+
schedule(key: string, params: ScheduleParams): void;
52+
/** Cancels both timers for a single key. */
53+
cancel(key: string): void;
54+
/** Cancels every key's timers (for cache `clear()` / `close()`). */
55+
cancelAll(): void;
56+
}
57+
58+
interface TimerHandles {
59+
expirationTimer?: ReturnType<typeof setTimeout>;
60+
refreshTimer?: ReturnType<typeof setTimeout>;
61+
}
62+
63+
// Teach ClerkJS not to block the exit of the event loop in Node environments.
64+
// https://nodejs.org/api/timers.html#timeoutunref
65+
const armTimer = (callback: () => void, delayMs: number): ReturnType<typeof setTimeout> => {
66+
const id = setTimeout(callback, delayMs);
67+
if (typeof (id as any).unref === 'function') {
68+
(id as any).unref();
69+
}
70+
return id;
71+
};
72+
73+
const clearHandles = (handles: TimerHandles) => {
74+
if (handles.expirationTimer !== undefined) {
75+
clearTimeout(handles.expirationTimer);
76+
}
77+
if (handles.refreshTimer !== undefined) {
78+
clearTimeout(handles.refreshTimer);
79+
}
80+
};
81+
82+
/**
83+
* Creates a {@link RefreshScheduler} bound to a {@link Clock} (defaults to the wall clock).
84+
*/
85+
export const createRefreshScheduler = (clock: Clock = { now: () => Date.now() / 1000 }): RefreshScheduler => {
86+
const timers = new Map<string, TimerHandles>();
87+
88+
const cancel = (key: string) => {
89+
const handles = timers.get(key);
90+
if (!handles) {
91+
return;
92+
}
93+
clearHandles(handles);
94+
timers.delete(key);
95+
};
96+
97+
const cancelAll = () => {
98+
timers.forEach(clearHandles);
99+
timers.clear();
100+
};
101+
102+
const schedule = (key: string, { expiresAt, onExpire, onRefresh }: ScheduleParams) => {
103+
cancel(key);
104+
105+
const now = clock.now();
106+
const handles: TimerHandles = {};
107+
108+
const expirationDelay = (expiresAt - now) * 1000;
109+
if (expirationDelay > 0) {
110+
handles.expirationTimer = armTimer(onExpire, expirationDelay);
111+
}
112+
113+
const leeway = Math.max(BACKGROUND_REFRESH_THRESHOLD_IN_SECONDS, POLLER_INTERVAL_IN_MS / 1000);
114+
const refreshDelay = (expiresAt - now - leeway - REFRESH_LEAD_TIME_IN_SECONDS) * 1000;
115+
if (refreshDelay > 0 && onRefresh) {
116+
handles.refreshTimer = armTimer(() => onRefresh(), refreshDelay);
117+
}
118+
119+
timers.set(key, handles);
120+
};
121+
122+
return { schedule, cancel, cancelAll };
123+
};

0 commit comments

Comments
 (0)