Skip to content

Commit 2cc7dbb

Browse files
bratsosjacekradko
andauthored
feat(repo): Make gettoken callable outside of react (#7325)
Co-authored-by: Jacek Radko <jacek@clerk.dev>
1 parent 0e651b3 commit 2cc7dbb

File tree

14 files changed

+467
-0
lines changed

14 files changed

+467
-0
lines changed

.changeset/slimy-hotels-give.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@clerk/tanstack-react-start': minor
3+
'@clerk/react-router': minor
4+
'@clerk/clerk-js': minor
5+
'@clerk/nextjs': minor
6+
'@clerk/shared': minor
7+
'@clerk/astro': minor
8+
'@clerk/react': minor
9+
'@clerk/nuxt': minor
10+
'@clerk/vue': minor
11+
---
12+
13+
Add standalone `getToken()` function for retrieving session tokens outside of framework component trees.
14+
15+
This function is safe to call from anywhere in the browser, such as API interceptors, data fetching layers (e.g., React Query, SWR), or vanilla JavaScript code. It automatically waits for Clerk to initialize before returning the token.
16+
17+
import { getToken } from '@clerk/nextjs'; // or any framework package
18+
19+
// Example: Axios interceptor
20+
axios.interceptors.request.use(async (config) => {
21+
const token = await getToken();
22+
if (token) {
23+
config.headers.Authorization = `Bearer ${token}`;
24+
}
25+
return config;
26+
});

packages/astro/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { updateClerkOptions } from '../internal/create-clerk-instance';
22
export * from '../stores/external';
3+
export { getToken } from '@clerk/shared/getToken';

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import type {
8282
InstanceType,
8383
JoinWaitlistParams,
8484
ListenerCallback,
85+
LoadedClerk,
8586
NavigateOptions,
8687
OrganizationListProps,
8788
OrganizationProfileProps,
@@ -437,6 +438,29 @@ export class Clerk implements ClerkInterface {
437438
this.#publicEventBus.emit(clerkEvents.Status, 'loading');
438439
this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s));
439440

441+
this.#publicEventBus.on(clerkEvents.Status, status => {
442+
if (!inBrowser()) {
443+
return;
444+
}
445+
if (status === 'ready' || status === 'degraded') {
446+
if (window.__clerk_internal_ready?.__resolve && this.#isLoaded()) {
447+
window.__clerk_internal_ready.__resolve(this);
448+
}
449+
} else if (status === 'error') {
450+
if (window.__clerk_internal_ready?.__reject) {
451+
window.__clerk_internal_ready.__reject(
452+
new ClerkRuntimeError('Clerk failed to initialize.', { code: 'clerk_init_failed' }),
453+
);
454+
}
455+
}
456+
});
457+
458+
if (inBrowser() && (this.#status === 'ready' || this.#status === 'degraded') && this.#isLoaded()) {
459+
if (window.__clerk_internal_ready?.__resolve) {
460+
window.__clerk_internal_ready.__resolve(this);
461+
}
462+
}
463+
440464
// This line is used for the piggy-backing mechanism
441465
BaseResource.clerk = this;
442466
this.#protect = new Protect();
@@ -3117,4 +3141,8 @@ export class Clerk implements ClerkInterface {
31173141

31183142
return allowedProtocols;
31193143
}
3144+
3145+
#isLoaded(): this is LoadedClerk {
3146+
return this.client !== undefined;
3147+
}
31203148
}

packages/clerk-js/src/global.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,15 @@ interface Window {
1919
__internal_onAfterSetActive: () => Promise<void> | void;
2020
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
2121
__internal_ClerkUiCtor?: import('@clerk/shared/types').ClerkUiConstructor;
22+
/**
23+
* Promise used for coordination between standalone getToken() from @clerk/shared and clerk-js.
24+
* When getToken() is called before Clerk loads, it creates this promise with __resolve/__reject callbacks.
25+
* When Clerk reaches ready/degraded/error status, it resolves/rejects this promise.
26+
*/
27+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
28+
__clerk_internal_ready?: Promise<import('@clerk/shared/types').LoadedClerk> & {
29+
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
30+
__resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void;
31+
__reject?: (error: Error) => void;
32+
};
2233
}

packages/nextjs/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export {
6161
useUser,
6262
} from './client-boundary/hooks';
6363

64+
export { getToken } from '@clerk/shared/getToken';
65+
6466
/**
6567
* Conditionally export components that exhibit different behavior
6668
* when used in /app vs /pages.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { createRouteMatcher } from './routeMatcher';
22
export { updateClerkOptions } from '@clerk/vue';
3+
export { getToken } from '@clerk/shared/getToken';

packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
5353
"__experimental_PaymentElementProvider",
5454
"__experimental_useCheckout",
5555
"__experimental_usePaymentElement",
56+
"getToken",
5657
"useAuth",
5758
"useClerk",
5859
"useEmailLink",

packages/react-router/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ if (typeof window !== 'undefined' && typeof (window as any).global === 'undefine
33
}
44

55
export * from './client';
6+
export { getToken } from '@clerk/shared/getToken';
67

78
// Override Clerk React error thrower to show that errors come from @clerk/react-router
89
import { setErrorThrowerOptions } from '@clerk/react/internal';

packages/react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './components';
99
export * from './contexts';
1010

1111
export * from './hooks';
12+
export { getToken } from '@clerk/shared/getToken';
1213
export type {
1314
BrowserClerk,
1415
BrowserClerkConstructor,
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { ClerkRuntimeError } from '../errors/clerkRuntimeError';
4+
import { getToken } from '../getToken';
5+
6+
describe('getToken', () => {
7+
const originalWindow = global.window;
8+
9+
beforeEach(() => {
10+
vi.useFakeTimers();
11+
});
12+
13+
afterEach(() => {
14+
vi.useRealTimers();
15+
vi.restoreAllMocks();
16+
global.window = originalWindow;
17+
});
18+
19+
describe('when Clerk is already ready', () => {
20+
it('should return token immediately', async () => {
21+
const mockToken = 'mock-jwt-token';
22+
const mockClerk = {
23+
status: 'ready',
24+
session: {
25+
getToken: vi.fn().mockResolvedValue(mockToken),
26+
},
27+
};
28+
29+
global.window = { Clerk: mockClerk } as any;
30+
31+
const token = await getToken();
32+
expect(token).toBe(mockToken);
33+
expect(mockClerk.session.getToken).toHaveBeenCalledWith(undefined);
34+
});
35+
36+
it('should pass options to session.getToken', async () => {
37+
const mockClerk = {
38+
status: 'ready',
39+
session: {
40+
getToken: vi.fn().mockResolvedValue('token'),
41+
},
42+
};
43+
44+
global.window = { Clerk: mockClerk } as any;
45+
46+
await getToken({ template: 'custom-template' });
47+
expect(mockClerk.session.getToken).toHaveBeenCalledWith({ template: 'custom-template' });
48+
});
49+
50+
it('should pass organizationId option to session.getToken', async () => {
51+
const mockClerk = {
52+
status: 'ready',
53+
session: {
54+
getToken: vi.fn().mockResolvedValue('token'),
55+
},
56+
};
57+
58+
global.window = { Clerk: mockClerk } as any;
59+
60+
await getToken({ organizationId: 'org_123' });
61+
expect(mockClerk.session.getToken).toHaveBeenCalledWith({ organizationId: 'org_123' });
62+
});
63+
});
64+
65+
describe('when Clerk is not yet ready', () => {
66+
it('should wait for promise resolution when clerk-js resolves the global promise', async () => {
67+
const mockToken = 'delayed-token';
68+
const mockClerk = {
69+
status: 'ready',
70+
session: {
71+
getToken: vi.fn().mockResolvedValue(mockToken),
72+
},
73+
};
74+
75+
// Start with empty window (no Clerk)
76+
global.window = {} as any;
77+
78+
const tokenPromise = getToken();
79+
80+
// Simulate clerk-js loading and resolving the promise
81+
await vi.advanceTimersByTimeAsync(100);
82+
83+
// Resolve the promise that getToken created
84+
const readyPromise = (global.window as any).__clerk_internal_ready;
85+
expect(readyPromise).toBeDefined();
86+
expect(readyPromise.__resolve).toBeDefined();
87+
88+
// Simulate clerk-js calling __resolve
89+
readyPromise.__resolve(mockClerk);
90+
91+
const token = await tokenPromise;
92+
expect(token).toBe(mockToken);
93+
});
94+
95+
it('should resolve when clerk-js resolves with degraded status', async () => {
96+
const mockToken = 'degraded-token';
97+
const mockClerk = {
98+
status: 'degraded',
99+
session: {
100+
getToken: vi.fn().mockResolvedValue(mockToken),
101+
},
102+
};
103+
104+
global.window = {} as any;
105+
106+
const tokenPromise = getToken();
107+
108+
await vi.advanceTimersByTimeAsync(100);
109+
110+
const readyPromise = (global.window as any).__clerk_internal_ready;
111+
readyPromise.__resolve(mockClerk);
112+
113+
const token = await tokenPromise;
114+
expect(token).toBe(mockToken);
115+
});
116+
117+
it('should reject when clerk-js rejects the global promise', async () => {
118+
global.window = {} as any;
119+
120+
const tokenPromise = getToken();
121+
122+
await vi.advanceTimersByTimeAsync(100);
123+
124+
const readyPromise = (global.window as any).__clerk_internal_ready;
125+
readyPromise.__reject(new Error('Clerk failed to initialize'));
126+
127+
await expect(tokenPromise).rejects.toThrow('Clerk failed to initialize');
128+
});
129+
130+
it('should throw ClerkRuntimeError if promise is never resolved (timeout)', async () => {
131+
global.window = {} as any;
132+
133+
let caughtError: unknown;
134+
const tokenPromise = getToken().catch(e => {
135+
caughtError = e;
136+
});
137+
138+
// Fast-forward past timeout (10 seconds)
139+
await vi.advanceTimersByTimeAsync(15000);
140+
await tokenPromise;
141+
142+
expect(caughtError).toBeInstanceOf(ClerkRuntimeError);
143+
expect((caughtError as ClerkRuntimeError).code).toBe('clerk_runtime_load_timeout');
144+
});
145+
});
146+
147+
describe('multiple concurrent getToken calls', () => {
148+
it('should share the same promise for concurrent calls', async () => {
149+
const mockToken = 'shared-token';
150+
const mockClerk = {
151+
status: 'ready',
152+
session: {
153+
getToken: vi.fn().mockResolvedValue(mockToken),
154+
},
155+
};
156+
157+
global.window = {} as any;
158+
159+
const tokenPromise1 = getToken();
160+
const tokenPromise2 = getToken();
161+
const tokenPromise3 = getToken();
162+
163+
await vi.advanceTimersByTimeAsync(100);
164+
165+
const readyPromise = (global.window as any).__clerk_internal_ready;
166+
readyPromise.__resolve(mockClerk);
167+
168+
const [token1, token2, token3] = await Promise.all([tokenPromise1, tokenPromise2, tokenPromise3]);
169+
170+
expect(token1).toBe(mockToken);
171+
expect(token2).toBe(mockToken);
172+
expect(token3).toBe(mockToken);
173+
expect(mockClerk.session.getToken).toHaveBeenCalledTimes(3);
174+
});
175+
});
176+
177+
describe('when user is not signed in', () => {
178+
it('should return null when session is null', async () => {
179+
const mockClerk = {
180+
status: 'ready',
181+
session: null,
182+
};
183+
184+
global.window = { Clerk: mockClerk } as any;
185+
186+
const token = await getToken();
187+
expect(token).toBeNull();
188+
});
189+
190+
it('should return null when session is undefined', async () => {
191+
const mockClerk = {
192+
status: 'ready',
193+
session: undefined,
194+
};
195+
196+
global.window = { Clerk: mockClerk } as any;
197+
198+
const token = await getToken();
199+
expect(token).toBeNull();
200+
});
201+
});
202+
203+
describe('when Clerk status is degraded', () => {
204+
it('should still return token', async () => {
205+
const mockToken = 'degraded-token';
206+
const mockClerk = {
207+
status: 'degraded',
208+
session: {
209+
getToken: vi.fn().mockResolvedValue(mockToken),
210+
},
211+
};
212+
213+
global.window = { Clerk: mockClerk } as any;
214+
215+
const token = await getToken();
216+
expect(token).toBe(mockToken);
217+
});
218+
});
219+
220+
describe('in non-browser environment', () => {
221+
it('should throw ClerkRuntimeError when window is undefined', async () => {
222+
global.window = undefined as any;
223+
224+
await expect(getToken()).rejects.toThrow(ClerkRuntimeError);
225+
await expect(getToken()).rejects.toMatchObject({
226+
code: 'clerk_runtime_not_browser',
227+
});
228+
});
229+
});
230+
231+
describe('when session.getToken throws', () => {
232+
it('should propagate the error', async () => {
233+
const mockClerk = {
234+
status: 'ready',
235+
session: {
236+
getToken: vi.fn().mockRejectedValue(new Error('Token fetch failed')),
237+
},
238+
};
239+
240+
global.window = { Clerk: mockClerk } as any;
241+
242+
await expect(getToken()).rejects.toThrow('Token fetch failed');
243+
});
244+
});
245+
246+
describe('fallback for older clerk-js versions', () => {
247+
it('should resolve when clerk.loaded is true but status is undefined', async () => {
248+
const mockToken = 'legacy-token';
249+
const mockClerk = {
250+
loaded: true,
251+
status: undefined,
252+
session: {
253+
getToken: vi.fn().mockResolvedValue(mockToken),
254+
},
255+
};
256+
257+
global.window = { Clerk: mockClerk } as any;
258+
259+
const token = await getToken();
260+
expect(token).toBe(mockToken);
261+
});
262+
});
263+
});

0 commit comments

Comments
 (0)