From 3d463d96a17b9342213ed991aa9cf76e0b9bcc40 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 27 Nov 2025 20:04:15 +0100 Subject: [PATCH 1/4] feat(repo): Add a library-agnostic `getToken` helper --- packages/astro/src/client/index.ts | 1 + packages/nextjs/src/index.ts | 2 + packages/nuxt/src/runtime/client/index.ts | 1 + packages/react-router/src/index.ts | 1 + packages/react/src/index.ts | 1 + .../shared/src/__tests__/getToken.spec.ts | 320 ++++++++++++++++++ packages/shared/src/getToken.ts | 160 +++++++++ packages/tanstack-react-start/src/index.ts | 1 + packages/vue/src/index.ts | 1 + 9 files changed, 488 insertions(+) create mode 100644 packages/shared/src/__tests__/getToken.spec.ts create mode 100644 packages/shared/src/getToken.ts diff --git a/packages/astro/src/client/index.ts b/packages/astro/src/client/index.ts index 3573d363730..20a7f7b4f0b 100644 --- a/packages/astro/src/client/index.ts +++ b/packages/astro/src/client/index.ts @@ -1,2 +1,3 @@ export { updateClerkOptions } from '../internal/create-clerk-instance'; export * from '../stores/external'; +export { getToken } from '@clerk/shared/getToken'; diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index b9c24e9b7ce..23e653be582 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -60,6 +60,8 @@ export { useUser, } from './client-boundary/hooks'; +export { getToken } from '@clerk/shared/getToken'; + /** * Conditionally export components that exhibit different behavior * when used in /app vs /pages. diff --git a/packages/nuxt/src/runtime/client/index.ts b/packages/nuxt/src/runtime/client/index.ts index 631ad5718bf..424c99be18e 100644 --- a/packages/nuxt/src/runtime/client/index.ts +++ b/packages/nuxt/src/runtime/client/index.ts @@ -1,2 +1,3 @@ export { createRouteMatcher } from './routeMatcher'; export { updateClerkOptions } from '@clerk/vue'; +export { getToken } from '@clerk/shared/getToken'; diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts index 3b94d578d24..6ee02505b4c 100644 --- a/packages/react-router/src/index.ts +++ b/packages/react-router/src/index.ts @@ -3,6 +3,7 @@ if (typeof window !== 'undefined' && typeof (window as any).global === 'undefine } export * from './client'; +export { getToken } from '@clerk/shared/getToken'; // Override Clerk React error thrower to show that errors come from @clerk/react-router import { setErrorThrowerOptions } from '@clerk/react/internal'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 97d841eaf1c..3ffd47e9e7d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -9,6 +9,7 @@ export * from './components'; export * from './contexts'; export * from './hooks'; +export { getToken } from '@clerk/shared/getToken'; export type { BrowserClerk, BrowserClerkConstructor, diff --git a/packages/shared/src/__tests__/getToken.spec.ts b/packages/shared/src/__tests__/getToken.spec.ts new file mode 100644 index 00000000000..3ef50adafc1 --- /dev/null +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -0,0 +1,320 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getToken } from '../getToken'; + +type StatusHandler = (status: string) => void; + +describe('getToken', () => { + const originalWindow = global.window; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + global.window = originalWindow; + }); + + describe('when Clerk is already ready', () => { + it('should return token immediately', async () => { + const mockToken = 'mock-jwt-token'; + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + expect(mockClerk.session.getToken).toHaveBeenCalledWith(undefined); + }); + + it('should pass options to session.getToken', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue('token'), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + await getToken({ template: 'custom-template' }); + expect(mockClerk.session.getToken).toHaveBeenCalledWith({ template: 'custom-template' }); + }); + + it('should pass organizationId option to session.getToken', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue('token'), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + await getToken({ organizationId: 'org_123' }); + expect(mockClerk.session.getToken).toHaveBeenCalledWith({ organizationId: 'org_123' }); + }); + }); + + describe('when Clerk is loading', () => { + it('should wait for ready status via event listener', async () => { + const mockToken = 'delayed-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk becoming ready + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'ready'; + if (statusHandler) { + (statusHandler as StatusHandler)('ready'); + } + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should resolve when status changes to degraded', async () => { + const mockToken = 'degraded-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk becoming degraded + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'degraded'; + if (statusHandler) { + (statusHandler as StatusHandler)('degraded'); + } + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + }); + + describe('when window.Clerk does not exist', () => { + it('should poll until Clerk is available', async () => { + const mockToken = 'polled-token'; + + global.window = {} as any; + + const tokenPromise = getToken(); + + // Simulate Clerk loading after 200ms + await vi.advanceTimersByTimeAsync(200); + + (global.window as any).Clerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + await vi.advanceTimersByTimeAsync(100); + + const token = await tokenPromise; + expect(token).toBe(mockToken); + }); + + it('should timeout and return null if Clerk never loads', async () => { + global.window = {} as any; + + const tokenPromise = getToken(); + + // Fast-forward past timeout (10 seconds) + await vi.advanceTimersByTimeAsync(15000); + + const token = await tokenPromise; + expect(token).toBeNull(); + }); + }); + + describe('when user is not signed in', () => { + it('should return null when session is null', async () => { + const mockClerk = { + status: 'ready', + session: null, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + + it('should return null when session is undefined', async () => { + const mockClerk = { + status: 'ready', + session: undefined, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + describe('when Clerk status is degraded', () => { + it('should still return token', async () => { + const mockToken = 'degraded-token'; + const mockClerk = { + status: 'degraded', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + }); + }); + + describe('in non-browser environment', () => { + it('should return null when window is undefined', async () => { + global.window = undefined as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + describe('when Clerk enters error status', () => { + it('should return null', async () => { + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: null, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + // Simulate Clerk entering error state + await vi.advanceTimersByTimeAsync(100); + mockClerk.status = 'error'; + if (statusHandler) { + (statusHandler as StatusHandler)('error'); + } + + const token = await tokenPromise; + expect(token).toBeNull(); + }); + }); + + describe('when session.getToken throws', () => { + it('should return null and not propagate the error', async () => { + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockRejectedValue(new Error('Token fetch failed')), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBeNull(); + }); + }); + + describe('fallback for older clerk-js versions', () => { + it('should resolve when clerk.loaded is true but status is undefined', async () => { + const mockToken = 'legacy-token'; + const mockClerk = { + loaded: true, + status: undefined, + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const token = await getToken(); + expect(token).toBe(mockToken); + }); + }); + + describe('cleanup', () => { + it('should unsubscribe from status listener on success', async () => { + const mockToken = 'cleanup-token'; + let statusHandler: StatusHandler | null = null; + + const mockClerk = { + status: 'loading' as string, + on: vi.fn((event: string, handler: StatusHandler) => { + if (event === 'status') { + statusHandler = handler; + } + }), + off: vi.fn(), + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = { Clerk: mockClerk } as any; + + const tokenPromise = getToken(); + + await vi.advanceTimersByTimeAsync(50); + mockClerk.status = 'ready'; + if (statusHandler) { + (statusHandler as StatusHandler)('ready'); + } + + await tokenPromise; + + // Verify cleanup was called + expect(mockClerk.off).toHaveBeenCalledWith('status', statusHandler); + }); + }); +}); diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts new file mode 100644 index 00000000000..04f6f95e903 --- /dev/null +++ b/packages/shared/src/getToken.ts @@ -0,0 +1,160 @@ +import { inBrowser } from './browser'; +import type { ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; + +const POLL_INTERVAL_MS = 50; +const MAX_POLL_RETRIES = 100; // 5 seconds of polling +const TIMEOUT_MS = 10000; // 10 second absolute timeout + +type WindowClerk = LoadedClerk & { + status?: ClerkStatus; + loaded?: boolean; + on?: (event: 'status', handler: (status: ClerkStatus) => void, opts?: { notify?: boolean }) => void; + off?: (event: 'status', handler: (status: ClerkStatus) => void) => void; +}; + +function getWindowClerk(): WindowClerk | undefined { + if (inBrowser() && 'Clerk' in window) { + return (window as unknown as { Clerk?: WindowClerk }).Clerk; + } + return undefined; +} + +class ClerkNotLoadedError extends Error { + constructor() { + super('Clerk: Timeout waiting for Clerk to load. Ensure ClerkProvider is mounted.'); + this.name = 'ClerkNotLoadedError'; + } +} + +class ClerkNotAvailableError extends Error { + constructor() { + super('Clerk: getToken can only be used in browser environments.'); + this.name = 'ClerkNotAvailableError'; + } +} + +function waitForClerk(): Promise { + return new Promise((resolve, reject) => { + if (!inBrowser()) { + reject(new ClerkNotAvailableError()); + return; + } + + const clerk = getWindowClerk(); + + if (clerk && (clerk.status === 'ready' || clerk.status === 'degraded')) { + resolve(clerk as LoadedClerk); + return; + } + + if (clerk && clerk.loaded && !clerk.status) { + resolve(clerk as LoadedClerk); + return; + } + + let retries = 0; + let timeoutId: ReturnType; + let statusHandler: ((status: ClerkStatus) => void) | undefined; + let pollTimeoutId: ReturnType; + let currentClerk: WindowClerk | undefined = clerk; + + const cleanup = () => { + clearTimeout(timeoutId); + clearTimeout(pollTimeoutId); + if (statusHandler && currentClerk?.off) { + currentClerk.off('status', statusHandler); + } + }; + + timeoutId = setTimeout(() => { + cleanup(); + reject(new ClerkNotLoadedError()); + }, TIMEOUT_MS); + + const checkAndResolve = () => { + currentClerk = getWindowClerk(); + + if (!currentClerk) { + if (retries < MAX_POLL_RETRIES) { + retries++; + pollTimeoutId = setTimeout(checkAndResolve, POLL_INTERVAL_MS); + } + return; + } + + if (currentClerk.status === 'ready' || currentClerk.status === 'degraded') { + cleanup(); + resolve(currentClerk as LoadedClerk); + return; + } + + if (currentClerk.loaded && !currentClerk.status) { + cleanup(); + resolve(currentClerk as LoadedClerk); + return; + } + + if (!statusHandler && currentClerk.on) { + statusHandler = (status: ClerkStatus) => { + if (status === 'ready' || status === 'degraded') { + cleanup(); + resolve(currentClerk as LoadedClerk); + } else if (status === 'error') { + cleanup(); + reject(new ClerkNotLoadedError()); + } + }; + + currentClerk.on('status', statusHandler, { notify: true }); + } + }; + + checkAndResolve(); + }); +} + +/** + * Retrieves the current session token, waiting for Clerk to initialize if necessary. + * + * This function is safe to call from anywhere in the browser + * + * @param options - Optional configuration for token retrieval + * @param options.template - The name of a JWT template to use + * @param options.organizationId - Organization ID to include in the token + * @param options.leewayInSeconds - Number of seconds of leeway for token expiration + * @param options.skipCache - Whether to skip the token cache + * @returns A Promise that resolves to the session token, or `null` if: + * - The user is not signed in + * - Clerk failed to load + * - Called in a non-browser environment + * + * @example + * ```typescript + * // In an Axios interceptor + * import { getToken } from '@clerk/nextjs'; + * + * axios.interceptors.request.use(async (config) => { + * const token = await getToken(); + * if (token) { + * config.headers.Authorization = `Bearer ${token}`; + * } + * return config; + * }); + * ``` + */ +export async function getToken(options?: GetTokenOptions): Promise { + try { + const clerk = await waitForClerk(); + + if (!clerk.session) { + return null; + } + + return await clerk.session.getToken(options); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('[Clerk] getToken failed:', error); + } + return null; + } +} diff --git a/packages/tanstack-react-start/src/index.ts b/packages/tanstack-react-start/src/index.ts index 4d1e3bee830..50218d443e5 100644 --- a/packages/tanstack-react-start/src/index.ts +++ b/packages/tanstack-react-start/src/index.ts @@ -1,4 +1,5 @@ export * from './client/index'; +export { getToken } from '@clerk/shared/getToken'; // Override Clerk React error thrower to show that errors come from @clerk/tanstack-react-start import { setErrorThrowerOptions } from '@clerk/react/internal'; diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 325f66ea890..6b04b6b5ce4 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -7,6 +7,7 @@ export * from './composables'; export { clerkPlugin, type PluginOptions } from './plugin'; export { updateClerkOptions } from './utils'; +export { getToken } from '@clerk/shared/getToken'; setErrorThrowerOptions({ packageName: PACKAGE_NAME }); setClerkJsLoadingErrorPackageName(PACKAGE_NAME); From 3b9d7d623c7fc885767b8822d0fc99b23101ea47 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 4 Dec 2025 21:43:53 +0100 Subject: [PATCH 2/4] fixup! feat(repo): Add a library-agnostic `getToken` helper --- .../shared/src/__tests__/getToken.spec.ts | 34 +++++--- packages/shared/src/getToken.ts | 81 ++++++++----------- 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/packages/shared/src/__tests__/getToken.spec.ts b/packages/shared/src/__tests__/getToken.spec.ts index 3ef50adafc1..ab5ee6f6b9d 100644 --- a/packages/shared/src/__tests__/getToken.spec.ts +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClerkRuntimeError } from '../errors/clerkRuntimeError'; import { getToken } from '../getToken'; type StatusHandler = (status: string) => void; @@ -153,16 +154,20 @@ describe('getToken', () => { expect(token).toBe(mockToken); }); - it('should timeout and return null if Clerk never loads', async () => { + it('should throw ClerkRuntimeError if Clerk never loads', async () => { global.window = {} as any; - const tokenPromise = getToken(); + let caughtError: unknown; + const tokenPromise = getToken().catch(e => { + caughtError = e; + }); // Fast-forward past timeout (10 seconds) await vi.advanceTimersByTimeAsync(15000); + await tokenPromise; - const token = await tokenPromise; - expect(token).toBeNull(); + expect(caughtError).toBeInstanceOf(ClerkRuntimeError); + expect((caughtError as ClerkRuntimeError).code).toBe('clerk_runtime_load_timeout'); }); }); @@ -210,16 +215,18 @@ describe('getToken', () => { }); describe('in non-browser environment', () => { - it('should return null when window is undefined', async () => { + it('should throw ClerkRuntimeError when window is undefined', async () => { global.window = undefined as any; - const token = await getToken(); - expect(token).toBeNull(); + await expect(getToken()).rejects.toThrow(ClerkRuntimeError); + await expect(getToken()).rejects.toMatchObject({ + code: 'clerk_runtime_not_browser', + }); }); }); describe('when Clerk enters error status', () => { - it('should return null', async () => { + it('should throw ClerkRuntimeError', async () => { let statusHandler: StatusHandler | null = null; const mockClerk = { @@ -244,13 +251,15 @@ describe('getToken', () => { (statusHandler as StatusHandler)('error'); } - const token = await tokenPromise; - expect(token).toBeNull(); + await expect(tokenPromise).rejects.toThrow(ClerkRuntimeError); + await expect(tokenPromise).rejects.toMatchObject({ + code: 'clerk_runtime_init_error', + }); }); }); describe('when session.getToken throws', () => { - it('should return null and not propagate the error', async () => { + it('should propagate the error', async () => { const mockClerk = { status: 'ready', session: { @@ -260,8 +269,7 @@ describe('getToken', () => { global.window = { Clerk: mockClerk } as any; - const token = await getToken(); - expect(token).toBeNull(); + await expect(getToken()).rejects.toThrow('Token fetch failed'); }); }); diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index 04f6f95e903..d44c2ca3848 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -1,42 +1,26 @@ import { inBrowser } from './browser'; -import type { ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; +import { ClerkRuntimeError } from './errors/clerkRuntimeError'; +import type { Clerk, ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; const POLL_INTERVAL_MS = 50; const MAX_POLL_RETRIES = 100; // 5 seconds of polling const TIMEOUT_MS = 10000; // 10 second absolute timeout -type WindowClerk = LoadedClerk & { - status?: ClerkStatus; - loaded?: boolean; - on?: (event: 'status', handler: (status: ClerkStatus) => void, opts?: { notify?: boolean }) => void; - off?: (event: 'status', handler: (status: ClerkStatus) => void) => void; -}; - -function getWindowClerk(): WindowClerk | undefined { +function getWindowClerk(): Clerk | undefined { if (inBrowser() && 'Clerk' in window) { - return (window as unknown as { Clerk?: WindowClerk }).Clerk; + return (window as unknown as { Clerk?: Clerk }).Clerk; } return undefined; } -class ClerkNotLoadedError extends Error { - constructor() { - super('Clerk: Timeout waiting for Clerk to load. Ensure ClerkProvider is mounted.'); - this.name = 'ClerkNotLoadedError'; - } -} - -class ClerkNotAvailableError extends Error { - constructor() { - super('Clerk: getToken can only be used in browser environments.'); - this.name = 'ClerkNotAvailableError'; - } -} - function waitForClerk(): Promise { return new Promise((resolve, reject) => { if (!inBrowser()) { - reject(new ClerkNotAvailableError()); + reject( + new ClerkRuntimeError('getToken can only be used in browser environments.', { + code: 'clerk_runtime_not_browser', + }), + ); return; } @@ -53,22 +37,25 @@ function waitForClerk(): Promise { } let retries = 0; - let timeoutId: ReturnType; let statusHandler: ((status: ClerkStatus) => void) | undefined; let pollTimeoutId: ReturnType; - let currentClerk: WindowClerk | undefined = clerk; + let currentClerk: Clerk | undefined = clerk; const cleanup = () => { clearTimeout(timeoutId); clearTimeout(pollTimeoutId); - if (statusHandler && currentClerk?.off) { + if (statusHandler && currentClerk) { currentClerk.off('status', statusHandler); } }; - timeoutId = setTimeout(() => { + const timeoutId = setTimeout(() => { cleanup(); - reject(new ClerkNotLoadedError()); + reject( + new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + code: 'clerk_runtime_load_timeout', + }), + ); }, TIMEOUT_MS); const checkAndResolve = () => { @@ -94,14 +81,18 @@ function waitForClerk(): Promise { return; } - if (!statusHandler && currentClerk.on) { + if (!statusHandler) { statusHandler = (status: ClerkStatus) => { if (status === 'ready' || status === 'degraded') { cleanup(); resolve(currentClerk as LoadedClerk); } else if (status === 'error') { cleanup(); - reject(new ClerkNotLoadedError()); + reject( + new ClerkRuntimeError('Clerk failed to initialize.', { + code: 'clerk_runtime_init_error', + }), + ); } }; @@ -123,10 +114,13 @@ function waitForClerk(): Promise { * @param options.organizationId - Organization ID to include in the token * @param options.leewayInSeconds - Number of seconds of leeway for token expiration * @param options.skipCache - Whether to skip the token cache - * @returns A Promise that resolves to the session token, or `null` if: - * - The user is not signed in - * - Clerk failed to load - * - Called in a non-browser environment + * @returns A Promise that resolves to the session token, or `null` if the user is not signed in + * + * @throws {ClerkRuntimeError} When called in a non-browser environment (code: `clerk_runtime_not_browser`) + * + * @throws {ClerkRuntimeError} When Clerk fails to load within timeout (code: `clerk_runtime_load_timeout`) + * + * @throws {ClerkRuntimeError} When Clerk fails to initialize (code: `clerk_runtime_init_error`) * * @example * ```typescript @@ -143,18 +137,11 @@ function waitForClerk(): Promise { * ``` */ export async function getToken(options?: GetTokenOptions): Promise { - try { - const clerk = await waitForClerk(); + const clerk = await waitForClerk(); - if (!clerk.session) { - return null; - } - - return await clerk.session.getToken(options); - } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.warn('[Clerk] getToken failed:', error); - } + if (!clerk.session) { return null; } + + return clerk.session.getToken(options); } From 638473b393126e13655fa799bfed94596afe90cc Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 4 Dec 2025 22:10:55 +0100 Subject: [PATCH 3/4] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/shared/src/getToken.ts | 142 +++++++++++++++----------------- 1 file changed, 68 insertions(+), 74 deletions(-) diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index d44c2ca3848..f35b96757ef 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -1,10 +1,11 @@ import { inBrowser } from './browser'; import { ClerkRuntimeError } from './errors/clerkRuntimeError'; +import { retry } from './retry'; import type { Clerk, ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; const POLL_INTERVAL_MS = 50; -const MAX_POLL_RETRIES = 100; // 5 seconds of polling -const TIMEOUT_MS = 10000; // 10 second absolute timeout +const MAX_POLL_RETRIES = 100; +const STATUS_TIMEOUT_MS = 10000; // 10 second timeout for status changes function getWindowClerk(): Clerk | undefined { if (inBrowser() && 'Clerk' in window) { @@ -13,95 +14,88 @@ function getWindowClerk(): Clerk | undefined { return undefined; } -function waitForClerk(): Promise { +function waitForClerkStatus(clerk: Clerk): Promise { return new Promise((resolve, reject) => { - if (!inBrowser()) { - reject( - new ClerkRuntimeError('getToken can only be used in browser environments.', { - code: 'clerk_runtime_not_browser', - }), - ); - return; - } - - const clerk = getWindowClerk(); + let settled = false; - if (clerk && (clerk.status === 'ready' || clerk.status === 'degraded')) { - resolve(clerk as LoadedClerk); - return; - } - - if (clerk && clerk.loaded && !clerk.status) { - resolve(clerk as LoadedClerk); - return; - } - - let retries = 0; - let statusHandler: ((status: ClerkStatus) => void) | undefined; - let pollTimeoutId: ReturnType; - let currentClerk: Clerk | undefined = clerk; + const statusHandler = (status: ClerkStatus) => { + if (settled) { + return; + } - const cleanup = () => { - clearTimeout(timeoutId); - clearTimeout(pollTimeoutId); - if (statusHandler && currentClerk) { - currentClerk.off('status', statusHandler); + if (status === 'ready' || status === 'degraded') { + settled = true; + clearTimeout(timeoutId); + clerk.off('status', statusHandler); + resolve(clerk as LoadedClerk); + } else if (status === 'error') { + settled = true; + clearTimeout(timeoutId); + clerk.off('status', statusHandler); + reject( + new ClerkRuntimeError('Clerk failed to initialize.', { + code: 'clerk_runtime_init_error', + }), + ); } }; const timeoutId = setTimeout(() => { - cleanup(); + if (settled) { + return; + } + settled = true; + clerk.off('status', statusHandler); reject( - new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + new ClerkRuntimeError('Timeout waiting for Clerk to initialize.', { code: 'clerk_runtime_load_timeout', }), ); - }, TIMEOUT_MS); - - const checkAndResolve = () => { - currentClerk = getWindowClerk(); + }, STATUS_TIMEOUT_MS); - if (!currentClerk) { - if (retries < MAX_POLL_RETRIES) { - retries++; - pollTimeoutId = setTimeout(checkAndResolve, POLL_INTERVAL_MS); - } - return; - } + clerk.on('status', statusHandler, { notify: true }); + }); +} - if (currentClerk.status === 'ready' || currentClerk.status === 'degraded') { - cleanup(); - resolve(currentClerk as LoadedClerk); - return; - } +async function waitForClerk(): Promise { + if (!inBrowser()) { + throw new ClerkRuntimeError('getToken can only be used in browser environments.', { + code: 'clerk_runtime_not_browser', + }); + } - if (currentClerk.loaded && !currentClerk.status) { - cleanup(); - resolve(currentClerk as LoadedClerk); - return; - } + let clerk: Clerk; + try { + clerk = await retry( + () => { + const clerk = getWindowClerk(); + if (!clerk) { + throw new Error('Clerk not found'); + } + return clerk; + }, + { + initialDelay: POLL_INTERVAL_MS, + factor: 1, + jitter: false, + shouldRetry: (_, iterations) => iterations < MAX_POLL_RETRIES, + }, + ); + } catch { + throw new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + code: 'clerk_runtime_load_timeout', + }); + } - if (!statusHandler) { - statusHandler = (status: ClerkStatus) => { - if (status === 'ready' || status === 'degraded') { - cleanup(); - resolve(currentClerk as LoadedClerk); - } else if (status === 'error') { - cleanup(); - reject( - new ClerkRuntimeError('Clerk failed to initialize.', { - code: 'clerk_runtime_init_error', - }), - ); - } - }; + if (clerk.status === 'ready' || clerk.status === 'degraded') { + return clerk as LoadedClerk; + } - currentClerk.on('status', statusHandler, { notify: true }); - } - }; + if (clerk.loaded && !clerk.status) { + return clerk as LoadedClerk; + } - checkAndResolve(); - }); + return waitForClerkStatus(clerk); } /** From 41a623bb1cf87933e22d233ba61f18449b288e63 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 5 Dec 2025 19:22:11 +0100 Subject: [PATCH 4/4] fixup! feat(repo): Add a library-agnostic `getToken` helper --- .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + .../src/__tests__/__snapshots__/exports.test.ts.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 54b196e9899..a6fe525e496 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -55,6 +55,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "__experimental_PaymentElementProvider", "__experimental_useCheckout", "__experimental_usePaymentElement", + "getToken", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 3e1c592195b..13ec7be9235 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -60,6 +60,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "__experimental_PaymentElementProvider", "__experimental_useCheckout", "__experimental_usePaymentElement", + "getToken", "useAuth", "useClerk", "useEmailLink",