From 5a25c7b42d511706eeb30318ad71046c85de0de4 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 27 Nov 2025 20:04:15 +0100 Subject: [PATCH 01/11] 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 c4123f6729c..20e5daa87db 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 5b505e054478feb0670686e9001b1220de95da27 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 4 Dec 2025 21:43:53 +0100 Subject: [PATCH 02/11] 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 faa263053936f2fafa7d21dadb8041c297772a35 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Thu, 4 Dec 2025 22:10:55 +0100 Subject: [PATCH 03/11] 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 fc8f922e1e8d9195d44171787a555749b7634093 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 5 Dec 2025 19:22:11 +0100 Subject: [PATCH 04/11] 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 e8f2ed3c6c2..8ea72969a9e 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -53,6 +53,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 21c0511015a..c354a0bef83 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 @@ -58,6 +58,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "__experimental_PaymentElementProvider", "__experimental_useCheckout", "__experimental_usePaymentElement", + "getToken", "useAuth", "useClerk", "useEmailLink", From 81be5590a2c478af4d8222839d8d2f98c4b9ef33 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 9 Jan 2026 18:41:20 +0200 Subject: [PATCH 05/11] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/clerk-js/src/core/clerk.ts | 19 ++ packages/clerk-js/src/global.d.ts | 17 ++ .../shared/src/__tests__/getToken.spec.ts | 177 ++++++------------ packages/shared/src/getToken.ts | 141 ++++++-------- 4 files changed, 152 insertions(+), 202 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 48d108389ce..bb83f820b5f 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -82,6 +82,7 @@ import type { InstanceType, JoinWaitlistParams, ListenerCallback, + LoadedClerk, NavigateOptions, OrganizationListProps, OrganizationProfileProps, @@ -437,6 +438,20 @@ export class Clerk implements ClerkInterface { this.#publicEventBus.emit(clerkEvents.Status, 'loading'); this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s)); + this.#publicEventBus.on(clerkEvents.Status, status => { + if (status === 'ready' || status === 'degraded') { + if (window.__clerk_internal_ready?.__resolve && this.#isLoaded()) { + window.__clerk_internal_ready.__resolve(this); + } + } else if (status === 'error') { + if (window.__clerk_internal_ready?.__reject) { + window.__clerk_internal_ready.__reject( + new ClerkRuntimeError('Clerk failed to initialize.', { code: 'clerk_init_failed' }), + ); + } + } + }); + // This line is used for the piggy-backing mechanism BaseResource.clerk = this; this.#protect = new Protect(); @@ -3117,4 +3132,8 @@ export class Clerk implements ClerkInterface { return allowedProtocols; } + + #isLoaded(): this is LoadedClerk { + return this.client !== undefined; + } } diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 8ecd6ad7635..e883c0d67b5 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -14,9 +14,26 @@ const __BUILD_DISABLE_RHC__: string; const __BUILD_VARIANT_CHANNEL__: boolean; const __BUILD_VARIANT_CHIPS__: boolean; +/** + * A promise used for coordination between standalone getToken() and clerk-js initialization. + * The __resolve and __reject callbacks allow external resolution. + */ +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +interface ClerkReadyPromise extends Promise { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + __resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void; + __reject?: (error: Error) => void; +} + interface Window { __internal_onBeforeSetActive: (intent?: 'sign-out') => Promise | void; __internal_onAfterSetActive: () => Promise | void; // eslint-disable-next-line @typescript-eslint/consistent-type-imports __internal_ClerkUiCtor?: import('@clerk/shared/types').ClerkUiConstructor; + /** + * Promise used for coordination between standalone getToken() from @clerk/shared and clerk-js. + * When getToken() is called before Clerk loads, it creates this promise with __resolve/__reject callbacks. + * When Clerk reaches ready/degraded/error status, it resolves/rejects this promise. + */ + __clerk_internal_ready?: ClerkReadyPromise; } diff --git a/packages/shared/src/__tests__/getToken.spec.ts b/packages/shared/src/__tests__/getToken.spec.ts index ab5ee6f6b9d..5682c27d256 100644 --- a/packages/shared/src/__tests__/getToken.spec.ts +++ b/packages/shared/src/__tests__/getToken.spec.ts @@ -3,8 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ClerkRuntimeError } from '../errors/clerkRuntimeError'; import { getToken } from '../getToken'; -type StatusHandler = (status: string) => void; - describe('getToken', () => { const originalWindow = global.window; @@ -64,97 +62,72 @@ describe('getToken', () => { }); }); - describe('when Clerk is loading', () => { - it('should wait for ready status via event listener', async () => { + describe('when Clerk is not yet ready', () => { + it('should wait for promise resolution when clerk-js resolves the global promise', 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(), + status: 'ready', session: { getToken: vi.fn().mockResolvedValue(mockToken), }, }; - global.window = { Clerk: mockClerk } as any; + // Start with empty window (no Clerk) + global.window = {} as any; const tokenPromise = getToken(); - // Simulate Clerk becoming ready + // Simulate clerk-js loading and resolving the promise await vi.advanceTimersByTimeAsync(100); - mockClerk.status = 'ready'; - if (statusHandler) { - (statusHandler as StatusHandler)('ready'); - } + + // Resolve the promise that getToken created + const readyPromise = (global.window as any).__clerk_internal_ready; + expect(readyPromise).toBeDefined(); + expect(readyPromise.__resolve).toBeDefined(); + + // Simulate clerk-js calling __resolve + readyPromise.__resolve(mockClerk); const token = await tokenPromise; expect(token).toBe(mockToken); }); - it('should resolve when status changes to degraded', async () => { + it('should resolve when clerk-js resolves with degraded status', 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(), + status: 'degraded', session: { getToken: vi.fn().mockResolvedValue(mockToken), }, }; - global.window = { Clerk: mockClerk } as any; + global.window = {} as any; const tokenPromise = getToken(); - // Simulate Clerk becoming degraded await vi.advanceTimersByTimeAsync(100); - mockClerk.status = 'degraded'; - if (statusHandler) { - (statusHandler as StatusHandler)('degraded'); - } + + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__resolve(mockClerk); 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'; + it('should reject when clerk-js rejects the global promise', async () => { 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); + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__reject(new Error('Clerk failed to initialize')); + + await expect(tokenPromise).rejects.toThrow('Clerk failed to initialize'); }); - it('should throw ClerkRuntimeError if Clerk never loads', async () => { + it('should throw ClerkRuntimeError if promise is never resolved (timeout)', async () => { global.window = {} as any; let caughtError: unknown; @@ -171,6 +144,36 @@ describe('getToken', () => { }); }); + describe('multiple concurrent getToken calls', () => { + it('should share the same promise for concurrent calls', async () => { + const mockToken = 'shared-token'; + const mockClerk = { + status: 'ready', + session: { + getToken: vi.fn().mockResolvedValue(mockToken), + }, + }; + + global.window = {} as any; + + const tokenPromise1 = getToken(); + const tokenPromise2 = getToken(); + const tokenPromise3 = getToken(); + + await vi.advanceTimersByTimeAsync(100); + + const readyPromise = (global.window as any).__clerk_internal_ready; + readyPromise.__resolve(mockClerk); + + const [token1, token2, token3] = await Promise.all([tokenPromise1, tokenPromise2, tokenPromise3]); + + expect(token1).toBe(mockToken); + expect(token2).toBe(mockToken); + expect(token3).toBe(mockToken); + expect(mockClerk.session.getToken).toHaveBeenCalledTimes(3); + }); + }); + describe('when user is not signed in', () => { it('should return null when session is null', async () => { const mockClerk = { @@ -225,39 +228,6 @@ describe('getToken', () => { }); }); - describe('when Clerk enters error status', () => { - it('should throw ClerkRuntimeError', 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'); - } - - await expect(tokenPromise).rejects.toThrow(ClerkRuntimeError); - await expect(tokenPromise).rejects.toMatchObject({ - code: 'clerk_runtime_init_error', - }); - }); - }); - describe('when session.getToken throws', () => { it('should propagate the error', async () => { const mockClerk = { @@ -290,39 +260,4 @@ describe('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 index f35b96757ef..8fbede2ebc6 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -1,60 +1,36 @@ import { inBrowser } from './browser'; import { ClerkRuntimeError } from './errors/clerkRuntimeError'; -import { retry } from './retry'; -import type { Clerk, ClerkStatus, GetTokenOptions, LoadedClerk } from './types'; +import type { GetTokenOptions, LoadedClerk } from './types'; -const POLL_INTERVAL_MS = 50; -const MAX_POLL_RETRIES = 100; -const STATUS_TIMEOUT_MS = 10000; // 10 second timeout for status changes +const TIMEOUT_MS = 10000; // 10 second timeout for Clerk to load -function getWindowClerk(): Clerk | undefined { - if (inBrowser() && 'Clerk' in window) { - return (window as unknown as { Clerk?: Clerk }).Clerk; - } - return undefined; +/** + * A promise that includes resolve/reject callbacks for external resolution. + * Used for coordination between getToken() and clerk-js initialization. + */ +interface ClerkReadyPromise extends Promise { + __resolve?: (clerk: LoadedClerk) => void; + __reject?: (error: Error) => void; } -function waitForClerkStatus(clerk: Clerk): Promise { - return new Promise((resolve, reject) => { - let settled = false; - - const statusHandler = (status: ClerkStatus) => { - if (settled) { - return; - } - - 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(() => { - if (settled) { - return; - } - settled = true; - clerk.off('status', statusHandler); - reject( - new ClerkRuntimeError('Timeout waiting for Clerk to initialize.', { - code: 'clerk_runtime_load_timeout', - }), - ); - }, STATUS_TIMEOUT_MS); +declare global { + interface Window { + __clerk_internal_ready?: ClerkReadyPromise; + } +} - clerk.on('status', statusHandler, { notify: true }); - }); +function getWindowClerk(): LoadedClerk | undefined { + if (inBrowser() && 'Clerk' in window) { + const clerk = (window as unknown as { Clerk?: LoadedClerk }).Clerk; + if (clerk && (clerk.status === 'ready' || clerk.status === 'degraded')) { + return clerk; + } + // Legacy fallback for older clerk-js versions without status + if (clerk?.loaded && !clerk.status) { + return clerk; + } + } + return undefined; } async function waitForClerk(): Promise { @@ -64,44 +40,49 @@ async function waitForClerk(): Promise { }); } - 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', - }); + const clerk = getWindowClerk(); + if (clerk) { + return clerk; } - if (clerk.status === 'ready' || clerk.status === 'degraded') { - return clerk as LoadedClerk; + // Get or create the coordination promise + if (!window.__clerk_internal_ready) { + let resolve: (clerk: LoadedClerk) => void; + let reject: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }) as ClerkReadyPromise; + promise.__resolve = resolve!; + promise.__reject = reject!; + window.__clerk_internal_ready = promise; } - if (clerk.loaded && !clerk.status) { - return clerk as LoadedClerk; - } - - return waitForClerkStatus(clerk); + return Promise.race([ + window.__clerk_internal_ready, + new Promise((_, reject) => + setTimeout( + () => + reject( + new ClerkRuntimeError('Timeout waiting for Clerk to load.', { + code: 'clerk_runtime_load_timeout', + }), + ), + TIMEOUT_MS, + ), + ), + ]); } /** * Retrieves the current session token, waiting for Clerk to initialize if necessary. * - * This function is safe to call from anywhere in the browser + * This function is safe to call from anywhere in the browser, such as API interceptors, + * data fetching layers, or vanilla JavaScript code. + * + * **Note:** In frameworks with concurrent rendering (e.g., React 18+), a global token read + * may not correspond to the currently committed UI during transitions. This is a coherence + * consideration, not an auth safety issue. * * @param options - Optional configuration for token retrieval * @param options.template - The name of a JWT template to use @@ -114,8 +95,6 @@ async function waitForClerk(): Promise { * * @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 * // In an Axios interceptor From dd2606339c2f8c48866919a2403223f5e9f43493 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Fri, 9 Jan 2026 19:11:31 +0200 Subject: [PATCH 06/11] chore: add changeset --- .changeset/slimy-hotels-give.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .changeset/slimy-hotels-give.md diff --git a/.changeset/slimy-hotels-give.md b/.changeset/slimy-hotels-give.md new file mode 100644 index 00000000000..e789aae85cc --- /dev/null +++ b/.changeset/slimy-hotels-give.md @@ -0,0 +1,26 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/astro': minor +'@clerk/react': minor +'@clerk/nuxt': minor +'@clerk/vue': minor +--- + +Add standalone `getToken()` function for retrieving session tokens outside of framework component trees. + +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. + +import { getToken } from '@clerk/nextjs'; // or any framework package + +// Example: Axios interceptor +axios.interceptors.request.use(async (config) => { + const token = await getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); From d6827569ac51dd27871eab0dba2f7dad83bf0878 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 12 Jan 2026 12:23:30 +0100 Subject: [PATCH 07/11] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/shared/src/getToken.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index 8fbede2ebc6..fc3fa22c23d 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -47,14 +47,14 @@ async function waitForClerk(): Promise { // Get or create the coordination promise if (!window.__clerk_internal_ready) { - let resolve: (clerk: LoadedClerk) => void; - let reject: (error: Error) => void; + let resolveRef: ((clerk: LoadedClerk) => void) | undefined; + let rejectRef: ((error: Error) => void) | undefined; const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; + resolveRef = res; + rejectRef = rej; }) as ClerkReadyPromise; - promise.__resolve = resolve!; - promise.__reject = reject!; + promise.__resolve = resolveRef; + promise.__reject = rejectRef; window.__clerk_internal_ready = promise; } From 7ea2a1a61e20f1adc0cc9d8f46b3908d13d9cfb1 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 12 Jan 2026 14:03:19 +0100 Subject: [PATCH 08/11] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/clerk-js/src/global.d.ts | 18 ++++++------------ packages/shared/src/getToken.ts | 9 ++++++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index e883c0d67b5..52a15e8dee6 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -14,17 +14,6 @@ const __BUILD_DISABLE_RHC__: string; const __BUILD_VARIANT_CHANNEL__: boolean; const __BUILD_VARIANT_CHIPS__: boolean; -/** - * A promise used for coordination between standalone getToken() and clerk-js initialization. - * The __resolve and __reject callbacks allow external resolution. - */ -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -interface ClerkReadyPromise extends Promise { - // eslint-disable-next-line @typescript-eslint/consistent-type-imports - __resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void; - __reject?: (error: Error) => void; -} - interface Window { __internal_onBeforeSetActive: (intent?: 'sign-out') => Promise | void; __internal_onAfterSetActive: () => Promise | void; @@ -35,5 +24,10 @@ interface Window { * When getToken() is called before Clerk loads, it creates this promise with __resolve/__reject callbacks. * When Clerk reaches ready/degraded/error status, it resolves/rejects this promise. */ - __clerk_internal_ready?: ClerkReadyPromise; + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + __clerk_internal_ready?: Promise & { + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + __resolve?: (clerk: import('@clerk/shared/types').LoadedClerk) => void; + __reject?: (error: Error) => void; + }; } diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index fc3fa22c23d..dce069affed 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -8,14 +8,17 @@ const TIMEOUT_MS = 10000; // 10 second timeout for Clerk to load * A promise that includes resolve/reject callbacks for external resolution. * Used for coordination between getToken() and clerk-js initialization. */ -interface ClerkReadyPromise extends Promise { +type ClerkReadyPromise = Promise & { __resolve?: (clerk: LoadedClerk) => void; __reject?: (error: Error) => void; -} +}; declare global { interface Window { - __clerk_internal_ready?: ClerkReadyPromise; + __clerk_internal_ready?: Promise & { + __resolve?: (clerk: LoadedClerk) => void; + __reject?: (error: Error) => void; + }; } } From cb48bea1c0ef52de36b4eed9b1cd4ca8e8436d2e Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 12 Jan 2026 14:29:12 +0100 Subject: [PATCH 09/11] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/shared/src/getToken.ts | 35 ++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/shared/src/getToken.ts b/packages/shared/src/getToken.ts index dce069affed..af2ad28d9ce 100644 --- a/packages/shared/src/getToken.ts +++ b/packages/shared/src/getToken.ts @@ -13,13 +13,12 @@ type ClerkReadyPromise = Promise & { __reject?: (error: Error) => void; }; -declare global { - interface Window { - __clerk_internal_ready?: Promise & { - __resolve?: (clerk: LoadedClerk) => void; - __reject?: (error: Error) => void; - }; - } +/** + * Local Window type extension for __clerk_internal_ready coordination. + * Avoids global augmentation to prevent declaration collisions for consumers. + */ +interface ClerkWindow extends Window { + __clerk_internal_ready?: ClerkReadyPromise; } function getWindowClerk(): LoadedClerk | undefined { @@ -48,21 +47,25 @@ async function waitForClerk(): Promise { return clerk; } + const clerkWindow = window as ClerkWindow; + // Get or create the coordination promise - if (!window.__clerk_internal_ready) { - let resolveRef: ((clerk: LoadedClerk) => void) | undefined; - let rejectRef: ((error: Error) => void) | undefined; + if (!clerkWindow.__clerk_internal_ready) { + let resolve!: (clerk: LoadedClerk) => void; + let reject!: (error: Error) => void; const promise = new Promise((res, rej) => { - resolveRef = res; - rejectRef = rej; + resolve = res; + reject = rej; }) as ClerkReadyPromise; - promise.__resolve = resolveRef; - promise.__reject = rejectRef; - window.__clerk_internal_ready = promise; + promise.__resolve = resolve; + promise.__reject = reject; + clerkWindow.__clerk_internal_ready = promise; } + const readyPromise = clerkWindow.__clerk_internal_ready; + return Promise.race([ - window.__clerk_internal_ready, + readyPromise, new Promise((_, reject) => setTimeout( () => From 258ac03f71328f82db17eb2bc195bb1caa3e5b89 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Mon, 12 Jan 2026 18:27:47 +0100 Subject: [PATCH 10/11] fixup! feat(repo): Add a library-agnostic `getToken` helper --- packages/clerk-js/src/core/clerk.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index bb83f820b5f..74f61cb62de 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -439,6 +439,9 @@ export class Clerk implements ClerkInterface { this.#publicEventBus.prioritizedOn(clerkEvents.Status, s => (this.#status = s)); this.#publicEventBus.on(clerkEvents.Status, status => { + if (!inBrowser()) { + return; + } if (status === 'ready' || status === 'degraded') { if (window.__clerk_internal_ready?.__resolve && this.#isLoaded()) { window.__clerk_internal_ready.__resolve(this); From b6350c6745e7cb1e6e3c5b8b87b5f1ce67237077 Mon Sep 17 00:00:00 2001 From: Alex Bratsos Date: Wed, 14 Jan 2026 18:35:39 +0100 Subject: [PATCH 11/11] wip --- packages/clerk-js/src/core/fraudProtection.ts | 17 ++- packages/clerk-js/src/core/resources/Base.ts | 25 ++-- .../clerk-js/src/core/resources/Session.ts | 47 ++++++- .../core/resources/__tests__/Session.test.ts | 119 ++++++++++++++++-- .../core/resources/__tests__/Token.test.ts | 39 ++---- packages/clerk-js/src/core/tokenCache.ts | 91 +++++++++++++- packages/shared/src/__tests__/error.spec.ts | 56 ++++++++- packages/shared/src/error.ts | 1 + .../shared/src/errors/clerkOfflineError.ts | 85 +++++++++++++ packages/shared/src/errors/helpers.ts | 30 ++++- 10 files changed, 445 insertions(+), 65 deletions(-) create mode 100644 packages/shared/src/errors/clerkOfflineError.ts diff --git a/packages/clerk-js/src/core/fraudProtection.ts b/packages/clerk-js/src/core/fraudProtection.ts index 8dcac4aebed..1d73e1d15b7 100644 --- a/packages/clerk-js/src/core/fraudProtection.ts +++ b/packages/clerk-js/src/core/fraudProtection.ts @@ -1,4 +1,9 @@ -import { ClerkRuntimeError, isClerkAPIResponseError, isClerkRuntimeError } from '@clerk/shared/error'; +import { + ClerkOfflineError, + ClerkRuntimeError, + isClerkAPIResponseError, + isClerkRuntimeError, +} from '@clerk/shared/error'; import { CaptchaChallenge } from '../utils/captcha/CaptchaChallenge'; import type { Clerk } from './resources/internal'; @@ -41,16 +46,22 @@ export class FraudProtection { return await cb(); } catch (e) { - if (!isClerkAPIResponseError(e)) { + // Offline errors should bypass captcha logic and be re-thrown immediately + // so cache fallback can be triggered + if (ClerkOfflineError.is(e)) { throw e; } // Network errors should bypass captcha logic and be re-thrown immediately - // so cache fallback can be triggered + // so higher layers can apply offline/cache handling. if (isClerkRuntimeError(e) && e.code === 'network_error') { throw e; } + if (!isClerkAPIResponseError(e)) { + throw e; + } + if (e.errors[0]?.code !== 'requires_captcha') { throw e; } diff --git a/packages/clerk-js/src/core/resources/Base.ts b/packages/clerk-js/src/core/resources/Base.ts index cc1b5db9819..61ec83ecb67 100644 --- a/packages/clerk-js/src/core/resources/Base.ts +++ b/packages/clerk-js/src/core/resources/Base.ts @@ -1,5 +1,5 @@ import { isValidBrowserOnline } from '@clerk/shared/browser'; -import { ClerkAPIResponseError, ClerkRuntimeError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError, ClerkRuntimeError, isNetworkError } from '@clerk/shared/error'; import { isProductionFromPublishableKey } from '@clerk/shared/keys'; import type { ClerkAPIErrorJSON, @@ -8,8 +8,6 @@ import type { DeletedObjectJSON, } from '@clerk/shared/types'; -import { debugLogger } from '@/utils/debug'; - import { clerkMissingFapiClientInResources } from '../errors'; import type { FapiClient, FapiRequestInit, FapiResponse, FapiResponseJSON, HTTPMethod } from '../fapiClient'; import { FraudProtection } from '../fraudProtection'; @@ -93,22 +91,23 @@ export abstract class BaseResource { try { fapiResponse = await BaseResource.fapiClient.request(requestInit, { fetchMaxTries }); } catch (e) { + if (ClerkOfflineError.is(e)) { + throw e; + } + + if (isNetworkError(e) || !isValidBrowserOnline()) { + throw new ClerkOfflineError('Network request failed', { + cause: e instanceof Error ? e : undefined, + }); + } + // TODO: This should be the default behavior in the next major version, as long as we have a way to handle the requests more gracefully when offline if (this.shouldRethrowOfflineNetworkErrors()) { // TODO @userland-errors: throw new ClerkRuntimeError(e?.message || e, { code: 'network_error', + cause: e instanceof Error ? e : undefined, }); - } else if (!isValidBrowserOnline()) { - debugLogger.warn( - 'Network request failed while offline, returning null', - { - method: requestInit.method, - path: requestInit.path, - }, - 'baseResource', - ); - return null; } else { throw e; } diff --git a/packages/clerk-js/src/core/resources/Session.ts b/packages/clerk-js/src/core/resources/Session.ts index b6fd29c81ed..e34cfaee343 100644 --- a/packages/clerk-js/src/core/resources/Session.ts +++ b/packages/clerk-js/src/core/resources/Session.ts @@ -1,5 +1,12 @@ import { createCheckAuthorization } from '@clerk/shared/authorization'; -import { ClerkWebAuthnError, is4xxError, MissingExpiredTokenError } from '@clerk/shared/error'; +import { isValidBrowserOnline } from '@clerk/shared/browser'; +import { + ClerkOfflineError, + ClerkWebAuthnError, + is4xxError, + isNetworkError, + MissingExpiredTokenError, +} from '@clerk/shared/error'; import { convertJSONToPublicKeyRequestOptions, serializePublicKeyCredentialAssertion, @@ -111,6 +118,14 @@ export class Session extends BaseResource implements SessionResource { maxDelayBetweenRetries: 50 * 1_000, jitter: false, shouldRetry: (error, iterationsCount) => { + // Don't retry offline errors - fapiClient already retried with a short window. + // Let the error propagate so the user can handle the offline state. + // Use code check for robustness across module boundaries. + const isOffline = + ClerkOfflineError.is(error) || (error as { code?: string })?.code === ClerkOfflineError.ERROR_CODE; + if (isOffline) { + return false; + } return !is4xxError(error) && iterationsCount <= 8; }, }); @@ -401,10 +416,38 @@ export class Session extends BaseResource implements SessionResource { const lastActiveToken = this.lastActiveToken?.getRawString(); - const tokenResolver = Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined).catch(e => { + const tokenResolver = retry(() => Token.create(path, params, skipCache ? { debug: 'skip_cache' } : undefined), { + retryImmediately: true, + initialDelay: 500, + maxDelayBetweenRetries: 2_000, + factor: 2, + jitter: false, + shouldRetry: (error, iterations) => { + return (ClerkOfflineError.is(error) || isNetworkError(error)) && iterations < 5; + }, + }).catch(e => { if (MissingExpiredTokenError.is(e) && lastActiveToken) { return Token.create(path, { ...params }, { expired_token: lastActiveToken }); } + // Detect offline/network errors and throw ClerkOfflineError + // Check both current online status AND if error is a network error. + // This handles the race condition where browser goes offline, request fails, + // then browser comes back online before we reach this catch block. + const networkError = isNetworkError(e); + const browserOnline = isValidBrowserOnline(); + + if (networkError || !browserOnline) { + const resolvedToken = SessionTokenCache.getResolvedToken({ tokenId }); + const hasCachedTokenForId = !!resolvedToken?.getRawString(); + + const offlineError = new ClerkOfflineError('Network request failed while offline', { + cause: e instanceof Error ? e : undefined, + }); + // Attach safe, non-sensitive context for consumers without leaking raw tokens. + (offlineError as any).hasCachedToken = hasCachedTokenForId; + (offlineError as any).tokenId = tokenId; + throw offlineError; + } throw e; }); SessionTokenCache.set({ tokenId, tokenResolver }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts index eb51257191d..599067622ea 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Session.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Session.test.ts @@ -1,4 +1,4 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; +import { ClerkAPIResponseError, ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType, OrganizationJSON, SessionJSON } from '@clerk/shared/types'; import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; @@ -269,13 +269,11 @@ describe('Session', () => { }); describe('with offline browser and network failure', () => { - let warnSpy; beforeEach(() => { Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(console, 'warn').mockReturnValue(); }); afterEach(() => { @@ -283,10 +281,9 @@ describe('Session', () => { writable: true, value: true, }); - warnSpy.mockRestore(); }); - it('returns null', async () => { + it('throws ClerkOfflineError', async () => { const session = new Session({ status: 'active', id: 'session_1', @@ -301,11 +298,117 @@ describe('Session', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await session.getToken(); + const p = session.getToken(); + await vi.runAllTimersAsync(); + await expect(p).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); + }); expect(global.fetch).toHaveBeenCalled(); - expect(dispatchSpy).toHaveBeenCalledTimes(1); - expect(token).toEqual(null); + }); + + it('includes cached token in offline error when available', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + // First call should use cached token successfully + const token1 = await session.getToken(); + expect(token1).toEqual(mockJwt); + + // Second call should throw offline error (refresh attempt) and indicate a cached token exists. + try { + const p = session.getToken({ skipCache: true }); + await vi.runAllTimersAsync(); + await p; + expect.fail('Expected getToken to throw'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + if (ClerkOfflineError.is(error)) { + expect((error as any).hasCachedToken).toBe(true); + } + } + }); + + it('does not poison cache on offline failure - preserves previous valid token', async () => { + BaseResource.clerk = clerkMock(); + + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + last_active_token: { object: 'token', jwt: mockJwt }, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + const token1 = await session.getToken(); + expect(token1).toEqual(mockJwt); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + try { + const p = session.getToken({ skipCache: true }); + await vi.runAllTimersAsync(); + await p; + expect.fail('Expected getToken to throw'); + } catch (error) { + expect(ClerkOfflineError.is(error)).toBe(true); + } + + BaseResource.clerk = clerkMock(); + + const token2 = await session.getToken(); + expect(token2).toEqual(mockJwt); + }); + + it('recovers successfully after coming back online', async () => { + const session = new Session({ + status: 'active', + id: 'session_1', + object: 'session', + user: createUser({}), + last_active_organization_id: null, + actor: null, + created_at: new Date().getTime(), + updated_at: new Date().getTime(), + } as SessionJSON); + + mockNetworkFailedFetch(); + BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; + + const p = session.getToken(); + await vi.runAllTimersAsync(); + await expect(p).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); + }); + + // Come back online + Object.defineProperty(window.navigator, 'onLine', { + writable: true, + value: true, + }); + BaseResource.clerk = clerkMock(); + + SessionTokenCache.clear(); + + const token = await session.getToken(); + expect(token).toEqual(mockJwt); }); }); diff --git a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts index d4738734267..4408f7d75d2 100644 --- a/packages/clerk-js/src/core/resources/__tests__/Token.test.ts +++ b/packages/clerk-js/src/core/resources/__tests__/Token.test.ts @@ -1,10 +1,9 @@ +import { ClerkOfflineError } from '@clerk/shared/error'; import type { InstanceType } from '@clerk/shared/types'; -import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, type Mock } from 'vitest'; import { mockFetch, mockJwt, mockNetworkFailedFetch } from '@/test/core-fixtures'; -import { debugLogger } from '@/utils/debug'; -import { SUPPORTED_FAPI_VERSION } from '../../constants'; import { createFapiClient } from '../../fapiClient'; import { BaseResource } from '../internal'; import { Token } from '../Token'; @@ -44,14 +43,11 @@ describe('Token', () => { }); describe('with offline browser and network failure', () => { - let warnSpy: ReturnType; - beforeEach(() => { Object.defineProperty(window.navigator, 'onLine', { writable: true, value: false, }); - warnSpy = vi.spyOn(debugLogger, 'warn').mockReturnValue(); }); afterEach(() => { @@ -59,27 +55,18 @@ describe('Token', () => { writable: true, value: true, }); - warnSpy.mockRestore(); }); - it('create returns empty raw string', async () => { + it('throws network error', async () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - const token = await Token.create('/path/to/tokens'); - - expect(global.fetch).toHaveBeenCalledTimes(1); - const [url, options] = (global.fetch as Mock).mock.calls[0]; - expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens'); - expect(options).toMatchObject({ - method: 'POST', - body: '', - credentials: 'include', - headers: expect.any(Headers), + await expect(Token.create('/path/to/tokens')).rejects.toSatisfy((error: unknown) => { + return ClerkOfflineError.is(error); }); - expect(token.getRawString()).toEqual(''); - expect(warnSpy).toBeCalled(); + // Should not retry when offline + expect(global.fetch).toHaveBeenCalledTimes(1); }); }); @@ -88,19 +75,9 @@ describe('Token', () => { mockNetworkFailedFetch(); BaseResource.clerk = { getFapiClient: () => createFapiClient(baseFapiClientOptions) } as any; - await expect(Token.create('/path/to/tokens')).rejects.toThrow( - `ClerkJS: Network error at "https://clerk.example.com/v1/path/to/tokens?__clerk_api_version=${SUPPORTED_FAPI_VERSION}&_clerk_js_version=test" - TypeError: Failed to fetch. Please try again.`, - ); + await expect(Token.create('/path/to/tokens')).rejects.toThrow(/ClerkJS: Network error/); expect(global.fetch).toHaveBeenCalledTimes(1); - const [url, options] = (global.fetch as Mock).mock.calls[0]; - expect(url.toString()).toContain('https://clerk.example.com/v1/path/to/tokens'); - expect(options).toMatchObject({ - method: 'POST', - body: '', - credentials: 'include', - headers: expect.any(Headers), - }); }); }); diff --git a/packages/clerk-js/src/core/tokenCache.ts b/packages/clerk-js/src/core/tokenCache.ts index 74697b80c63..26ecab846f6 100644 --- a/packages/clerk-js/src/core/tokenCache.ts +++ b/packages/clerk-js/src/core/tokenCache.ts @@ -1,3 +1,4 @@ +import { ClerkOfflineError } from '@clerk/shared/error'; import type { TokenResource } from '@clerk/shared/types'; import { debugLogger } from '@/utils/debug'; @@ -39,6 +40,11 @@ interface TokenCacheValue { createdAt: Seconds; entry: TokenCacheEntry; expiresIn?: Seconds; + /** + * The last successfully resolved token for this cache key. + * Used to serve a valid token while a refresh is in-flight. + */ + resolvedToken?: TokenResource; timeoutId?: ReturnType; } @@ -64,6 +70,16 @@ export interface TokenCache { */ get(cacheKeyJSON: TokenCacheKeyJSON, leeway?: number): TokenCacheEntry | undefined; + /** + * Retrieves the last successfully resolved token for a cache key. + * This is useful for getting a valid token while a refresh is in-flight, + * or for attaching to error context. + * + * @param cacheKeyJSON - Object containing tokenId and optional audience + * @returns The resolved TokenResource if available, undefined otherwise + */ + getResolvedToken(cacheKeyJSON: TokenCacheKeyJSON): TokenResource | undefined; + /** * Stores a token entry in the cache and broadcasts to other tabs when the token resolves. * @@ -203,6 +219,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return value.entry; }; + /** + * Returns the last successfully resolved token for a cache key. + * Useful for serving a valid token while a refresh is in-flight, + * or for attaching to error context. + */ + const getResolvedToken = (cacheKeyJSON: TokenCacheKeyJSON): TokenResource | undefined => { + const cacheKey = new TokenCacheKey(prefix, cacheKeyJSON); + const value = cache.get(cacheKey.toKey()); + + return value?.resolvedToken; + }; + /** * Processes token updates from other tabs via BroadcastChannel. * Validates token ID, parses JWT, and updates cache if token is newer than existing entry. @@ -314,9 +342,18 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const key = cacheKey.toKey(); + // Store previous entry for potential rollback on offline failure + const previousValue = cache.get(key); + const nowSeconds = Math.floor(Date.now() / 1000); const createdAt = entry.createdAt ?? nowSeconds; - const value: TokenCacheValue = { createdAt, entry, expiresIn: undefined }; + // Preserve the previous resolved token while the new request is in-flight + const value: TokenCacheValue = { + createdAt, + entry, + expiresIn: undefined, + resolvedToken: previousValue?.resolvedToken, + }; const deleteKey = () => { const cachedValue = cache.get(key); @@ -328,6 +365,42 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } }; + const rollbackToPrevious = () => { + // Only rollback if current entry is still the one we're handling + const cachedValue = cache.get(key); + if (cachedValue !== value) { + debugLogger.debug('Rollback skipped (entry changed)', { tokenId: entry.tokenId }, 'tokenCache'); + return; + } + + // Restore previous entry if it had resolved successfully. + // Use `resolvedToken` as the source of truth (expiresIn may be missing in edge cases). + if (previousValue?.resolvedToken) { + // Ensure the restored entry will still be cleaned up when it expires. + // (Its original timer might have been cleared or already fired in rare cases.) + if (previousValue.timeoutId === undefined && previousValue.expiresIn !== undefined) { + const nowSeconds = Math.floor(Date.now() / 1000); + const elapsed = nowSeconds - previousValue.createdAt; + const remainingSeconds = previousValue.expiresIn - elapsed; + if (remainingSeconds > 0) { + const timeoutId = setTimeout(() => { + const current = cache.get(key); + if (current === previousValue) { + cache.delete(key); + } + }, remainingSeconds * 1000); + previousValue.timeoutId = timeoutId; + if (typeof (timeoutId as any).unref === 'function') { + (timeoutId as any).unref(); + } + } + } + + cache.set(key, previousValue); + } else { + } + }; + entry.tokenResolver .then(newToken => { const claims = newToken.jwt?.claims; @@ -340,6 +413,8 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { const expiresIn: Seconds = expiresAt - issuedAt; value.expiresIn = expiresIn; + // Store the successfully resolved token for use while future refreshes are in-flight + value.resolvedToken = newToken; const timeoutId = setTimeout(deleteKey, expiresIn * 1000); value.timeoutId = timeoutId; @@ -389,8 +464,16 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { } } }) - .catch(() => { - deleteKey(); + .catch(error => { + const isOffline = ClerkOfflineError.is(error); + + if (isOffline) { + // Rollback to previous valid entry on offline failure + // This prevents cache poisoning where a rejected promise blocks recovery + rollbackToPrevious(); + } else { + deleteKey(); + } }); cache.set(key, value); @@ -407,7 +490,7 @@ const MemoryTokenCache = (prefix = KEY_PREFIX): TokenCache => { return cache.size; }; - return { clear, close, get, set, size }; + return { clear, close, get, getResolvedToken, set, size }; }; export const SessionTokenCache = MemoryTokenCache(); diff --git a/packages/shared/src/__tests__/error.spec.ts b/packages/shared/src/__tests__/error.spec.ts index d04a312977a..1eeaadedfc3 100644 --- a/packages/shared/src/__tests__/error.spec.ts +++ b/packages/shared/src/__tests__/error.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { ErrorThrowerOptions } from '../error'; -import { buildErrorThrower, ClerkRuntimeError, isClerkRuntimeError } from '../error'; +import { buildErrorThrower, ClerkOfflineError, ClerkRuntimeError, isClerkRuntimeError } from '../error'; describe('ErrorThrower', () => { const errorThrower = buildErrorThrower({ packageName: '@clerk/test-package' }); @@ -62,3 +62,57 @@ describe('ClerkRuntimeError', () => { expect(isClerkRuntimeError(clerkRuntimeError)).toEqual(true); }); }); + +describe('ClerkOfflineError', () => { + it('has the correct error code constant', () => { + expect(ClerkOfflineError.ERROR_CODE).toEqual('clerk_offline'); + }); + + it('can be instantiated with message', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline'); + expect(offlineError.message).toContain('Network request failed while offline'); + expect(offlineError.message).toContain('clerk_offline'); + expect(offlineError.code).toBe('clerk_offline'); + }); + + it('identifies ClerkOfflineError instances', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline'); + expect(ClerkOfflineError.is(offlineError)).toBe(true); + }); + + it('does not identify ClerkRuntimeError with different code', () => { + const otherError = new ClerkRuntimeError('Some other error', { + code: 'other_error', + }); + expect(ClerkOfflineError.is(otherError)).toBe(false); + }); + + it('does not identify non-ClerkOfflineError', () => { + expect(ClerkOfflineError.is(new Error('regular error'))).toBe(false); + expect(ClerkOfflineError.is({ code: 'clerk_offline' })).toBe(false); + expect(ClerkOfflineError.is(null)).toBe(false); + expect(ClerkOfflineError.is(undefined)).toBe(false); + }); + + it('supports non-sensitive context properties', () => { + const offlineError = new ClerkOfflineError('Network request failed while offline', { + hasCachedToken: true, + tokenId: 'sess_123', + }); + + expect(ClerkOfflineError.is(offlineError)).toBe(true); + expect(offlineError.hasCachedToken).toBe(true); + expect(offlineError.tokenId).toBe('sess_123'); + expect(offlineError.code).toEqual('clerk_offline'); + }); + + it('preserves cause from original error', () => { + const originalError = new Error('Original network error'); + const offlineError = new ClerkOfflineError('Network request failed while offline', { + cause: originalError, + }); + + expect(ClerkOfflineError.is(offlineError)).toBe(true); + expect(offlineError.cause).toBe(originalError); + }); +}); diff --git a/packages/shared/src/error.ts b/packages/shared/src/error.ts index 328a363015e..b75e569a5db 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -4,6 +4,7 @@ export { ClerkAPIError, isClerkAPIError } from './errors/clerkApiError'; export { ClerkAPIResponseError, isClerkAPIResponseError } from './errors/clerkApiResponseError'; export { ClerkError, isClerkError } from './errors/clerkError'; export { MissingExpiredTokenError } from './errors/missingExpiredTokenError'; +export { ClerkOfflineError } from './errors/clerkOfflineError'; export { buildErrorThrower, type ErrorThrower, type ErrorThrowerOptions } from './errors/errorThrower'; diff --git a/packages/shared/src/errors/clerkOfflineError.ts b/packages/shared/src/errors/clerkOfflineError.ts new file mode 100644 index 00000000000..a82c243d9ad --- /dev/null +++ b/packages/shared/src/errors/clerkOfflineError.ts @@ -0,0 +1,85 @@ +import type { ClerkErrorParams } from './clerkError'; +import { ClerkError } from './clerkError'; +import { createErrorTypeGuard } from './createErrorTypeGuard'; + +type ClerkOfflineErrorOptions = Omit & { + /** + * Whether a cached token exists for the requested tokenId. + * This avoids leaking raw tokens into error objects (which are often logged/serialized). + */ + hasCachedToken?: boolean; + /** + * The tokenId that was being requested when the offline/connectivity failure happened. + */ + tokenId?: string; + /** + * @deprecated Avoid attaching raw tokens to errors. Prefer `hasCachedToken` + re-calling `getToken()`. + */ + cachedToken?: string; +}; + +/** + * Error class for offline/network failure scenarios. + * + * Thrown when a network request fails due to the user being offline or + * experiencing network connectivity issues. This allows applications to + * distinguish between API errors and connectivity problems. + * + * @example + * ```typescript + * try { + * const token = await session.getToken(); + * } catch (e) { + * if (ClerkOfflineError.is(e)) { + * // Handle offline scenario + * showOfflineMessage(); + * // Access cached token if available + * if (e.cachedToken) { + * useFallbackToken(e.cachedToken); + * } + * } + * } + * ``` + */ +export class ClerkOfflineError extends ClerkError { + static kind = 'ClerkOfflineError'; + static readonly ERROR_CODE = 'clerk_offline' as const; + + /** + * Whether a cached token exists for the requested tokenId. + */ + readonly hasCachedToken?: boolean; + /** + * The tokenId that was being requested when the offline/connectivity failure happened. + */ + readonly tokenId?: string; + /** + * @deprecated Avoid attaching raw tokens to errors. Prefer `hasCachedToken` + re-calling `getToken()`. + */ + readonly cachedToken?: string; + + constructor(message: string, options?: ClerkOfflineErrorOptions) { + super({ + ...options, + message, + code: ClerkOfflineError.ERROR_CODE, + }); + this.hasCachedToken = options?.hasCachedToken; + this.tokenId = options?.tokenId; + this.cachedToken = options?.cachedToken; + Object.setPrototypeOf(this, ClerkOfflineError.prototype); + } + + /** + * Type guard to check if an error is a ClerkOfflineError. + * + * @example + * ```typescript + * if (ClerkOfflineError.is(error)) { + * // error is typed as ClerkOfflineError + * console.log(error.cachedToken); + * } + * ``` + */ + static is = createErrorTypeGuard(ClerkOfflineError); +} diff --git a/packages/shared/src/errors/helpers.ts b/packages/shared/src/errors/helpers.ts index b95f55d5a8c..953002b3777 100644 --- a/packages/shared/src/errors/helpers.ts +++ b/packages/shared/src/errors/helpers.ts @@ -43,9 +43,33 @@ export function is4xxError(e: any): boolean { * @internal */ export function isNetworkError(e: any): boolean { - // TODO: revise during error handling epic - const message = (`${e.message}${e.name}` || '').toLowerCase().replace(/\s+/g, ''); - return message.includes('networkerror'); + if (!e) { + return false; + } + + const name = String(e.name || ''); + if (name === 'AbortError') { + return false; + } + + const message = String(e.message || ''); + const haystack = `${name} ${message}`.toLowerCase(); + + if (haystack.includes('clerkjs: network error at')) { + return true; + } + + if (haystack.includes('failed to fetch')) { + return true; + } + if (haystack.includes('networkerror when attempting to fetch resource')) { + return true; + } + if (haystack.includes('load failed')) { + return true; + } + + return false; } /**