diff --git a/.changeset/shared-navigator-service-worker.md b/.changeset/shared-navigator-service-worker.md new file mode 100644 index 00000000000..330610ec3c8 --- /dev/null +++ b/.changeset/shared-navigator-service-worker.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Resolve the browser connectivity heuristics (`isValidBrowser`, `isBrowserOnline`, and therefore `isValidBrowserOnline`) from the global `navigator` when `window` is unavailable. In runtimes that have no `window` but do expose a global `navigator` — most notably an MV3 extension background **service worker** (where `@clerk/chrome-extension` loads the background client) — these checks previously always reported "invalid/offline". That caused `getToken()` failures to be re-thrown as a misleading `clerk_offline` error and capped network retries lower than intended. The checks now read real connectivity from the worker's `navigator`. Environments with no navigator at all (e.g. SSR) continue to report `false`, and behavior in standard browsers and React Native is unchanged. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cd0dacad616..7747c8b59e6 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "549KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "71.18KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "71.24KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "114KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "316KB" }, { "path": "./dist/clerk.native.js", "maxSize": "72KB" }, diff --git a/packages/shared/src/__tests__/browser.spec.ts b/packages/shared/src/__tests__/browser.spec.ts index cc2626d6b7a..8380d0c3668 100644 --- a/packages/shared/src/__tests__/browser.spec.ts +++ b/packages/shared/src/__tests__/browser.spec.ts @@ -2,6 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { inBrowser, isValidBrowser, isValidBrowserOnline, userAgentIsRobot } from '../browser'; +/** + * Simulates a Web/Service Worker global scope (e.g. an MV3 background service worker): + * no `window`, but a `WorkerGlobalScope` exposing a `WorkerNavigator` as `self.navigator`. + * Reuses the existing jsdom navigator (with its property spies) as the worker navigator so + * userAgent/onLine/webdriver getters keep applying. + */ +function mockServiceWorkerScope() { + const workerNavigator = window.navigator; + class WorkerGlobalScope {} + const workerSelf = Object.create(WorkerGlobalScope.prototype); + workerSelf.navigator = workerNavigator; + vi.stubGlobal('WorkerGlobalScope', WorkerGlobalScope); + vi.stubGlobal('self', workerSelf); + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); +} + describe('inBrowser()', () => { afterEach(() => { vi.restoreAllMocks(); @@ -36,9 +54,12 @@ describe('isValidBrowser', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); - it('returns false if not in browser', () => { + it('returns false when there is no window and no WorkerGlobalScope (e.g. Node SSR)', () => { + // Modern Node exposes `globalThis.navigator`, so the bare global navigator stays + // defined here. Without a `WorkerGlobalScope`, SSR must still be treated as non-browser. const windowSpy = vi.spyOn(global, 'window', 'get'); // @ts-ignore - Test windowSpy.mockReturnValue(undefined); @@ -46,6 +67,16 @@ describe('isValidBrowser', () => { expect(isValidBrowser()).toBe(false); }); + it('returns true in a service worker (no window) when a valid WorkerNavigator is present', () => { + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(true); + }); + it('returns true if in browser, navigator is not a bot, and webdriver is not enabled', () => { userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', @@ -131,6 +162,11 @@ describe('isValidBrowserOnline', () => { connectionGetter = vi.spyOn(window.navigator, 'connection', 'get'); }); + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + it('returns TRUE if connection is online, navigator is online, has disabled webdriver, and not a bot', () => { userAgentGetter.mockReturnValue( 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0', @@ -207,4 +243,36 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(true); }); + + it('returns TRUE in a service worker (no window) when the WorkerNavigator reports online', () => { + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + onLineGetter.mockReturnValue(true); + + expect(isValidBrowserOnline()).toBe(true); + }); + + it('returns FALSE in a service worker (no window) when the WorkerNavigator reports offline', () => { + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue( + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + ); + webdriverGetter.mockReturnValue(false); + onLineGetter.mockReturnValue(false); + + expect(isValidBrowserOnline()).toBe(false); + }); + + it('returns FALSE when there is no window and no WorkerGlobalScope (e.g. Node SSR)', () => { + // Modern Node exposes `globalThis.navigator`, so the bare global navigator stays + // defined here. Without a `WorkerGlobalScope`, SSR must still be treated as non-browser. + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + + expect(isValidBrowserOnline()).toBe(false); + }); }); diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts index 2e48c090a40..a129de01366 100644 --- a/packages/shared/src/browser.ts +++ b/packages/shared/src/browser.ts @@ -49,13 +49,45 @@ export function userAgentIsRobot(userAgent: string): boolean { return !userAgent ? false : botAgentRegex.test(userAgent); } +/** + * Resolves the `Navigator` object from either the DOM `window` (standard browsers) + * or a Web/Service Worker global scope. An MV3 extension background service worker + * has no `window`, but runs inside a `WorkerGlobalScope` that exposes a + * `WorkerNavigator` as `self.navigator` with the `onLine`/`userAgent` properties + * our heuristics rely on. + * + * We intentionally gate the worker fallback on a real `WorkerGlobalScope` rather than + * accepting any global `navigator`. Modern Node exposes `globalThis.navigator`, so a + * blanket global-navigator check would make Node SSR look like a browser; requiring a + * `WorkerGlobalScope` keeps SSR returning `null`. + * + * @returns + */ +function getNavigator(): Navigator | null { + if (typeof window !== 'undefined' && window.navigator) { + return window.navigator; + } + const workerScope = globalThis as unknown as { + WorkerGlobalScope?: new (...args: never[]) => { navigator?: Navigator }; + self?: { navigator?: Navigator }; + }; + if ( + typeof workerScope.WorkerGlobalScope === 'function' && + workerScope.self instanceof workerScope.WorkerGlobalScope && + workerScope.self.navigator + ) { + return workerScope.self.navigator; + } + return null; +} + /** * Checks if the current environment is a browser and the user agent is not a bot. * * @returns */ export function isValidBrowser(): boolean { - const navigator = inBrowser() ? window?.navigator : null; + const navigator = getNavigator(); if (!navigator) { return false; } @@ -68,7 +100,7 @@ export function isValidBrowser(): boolean { * @returns */ export function isBrowserOnline(): boolean { - const navigator = inBrowser() ? window?.navigator : null; + const navigator = getNavigator(); if (!navigator) { return false; } diff --git a/packages/shared/src/webauthn.ts b/packages/shared/src/webauthn.ts index ff934ed8dd0..c23c5f7582f 100644 --- a/packages/shared/src/webauthn.ts +++ b/packages/shared/src/webauthn.ts @@ -5,6 +5,10 @@ import { isValidBrowser } from './browser'; */ function isWebAuthnSupported() { return ( + // `isValidBrowser()` now also returns true in environments that expose a global + // `navigator` but no `window` (e.g. service workers). WebAuthn requires the DOM + // `window` (it reads `window.PublicKeyCredential`), so guard on it explicitly. + typeof window !== 'undefined' && isValidBrowser() && // Check if `PublicKeyCredential` is a constructor typeof window.PublicKeyCredential === 'function'