diff --git a/.changeset/shared-worker-navigator-server-runtimes.md b/.changeset/shared-worker-navigator-server-runtimes.md new file mode 100644 index 00000000000..8138886d605 --- /dev/null +++ b/.changeset/shared-worker-navigator-server-runtimes.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Exclude self-identified server runtimes (`Cloudflare-Workers`, `Node.js`, `Deno`, `Bun` user agents) from the worker-scope `navigator` fallback used by `isValidBrowser`, `isBrowserOnline`, and `isValidBrowserOnline`. Today Cloudflare's workerd is excluded only because its `self` does not satisfy `instanceof WorkerGlobalScope`; this guard keeps the checks returning `false` on server-side worker runtimes even if that implementation detail changes, while real browser web/service workers (such as MV3 extension background workers) are unaffected. diff --git a/packages/shared/src/__tests__/browser.spec.ts b/packages/shared/src/__tests__/browser.spec.ts index 8380d0c3668..a2013c3acc7 100644 --- a/packages/shared/src/__tests__/browser.spec.ts +++ b/packages/shared/src/__tests__/browser.spec.ts @@ -77,6 +77,42 @@ describe('isValidBrowser', () => { expect(isValidBrowser()).toBe(true); }); + it('returns false in a worker scope whose navigator identifies a server runtime (e.g. Cloudflare Workers)', () => { + // Today workerd's `self` fails the `instanceof WorkerGlobalScope` gate, but that is a + // quirk of its prototype chain. This simulates a spec-compliant workerd: full worker + // scope, navigator present, but the user agent self-identifies as a server runtime. + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue('Cloudflare-Workers'); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(false); + }); + + it.each(['Node.js/24', 'Deno/2.5.0', 'Bun/1.3.9'])( + 'returns false in a worker scope whose navigator identifies the %s server runtime', + userAgent => { + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue(userAgent); + webdriverGetter.mockReturnValue(false); + + expect(isValidBrowser()).toBe(false); + }, + ); + + it('returns false when WorkerGlobalScope exists but self is not an instance of it (e.g. workerd today)', () => { + // workerd exposes the WorkerGlobalScope constructor without linking `self` to its + // prototype chain, so the instanceof gate must reject it even with a browser-like UA. + vi.stubGlobal('WorkerGlobalScope', class {}); + vi.stubGlobal('self', { + navigator: { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36', onLine: true, webdriver: false }, + }); + const windowSpy = vi.spyOn(global, 'window', 'get'); + // @ts-ignore - Test + windowSpy.mockReturnValue(undefined); + + expect(isValidBrowser()).toBe(false); + }); + 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', @@ -275,4 +311,15 @@ describe('isValidBrowserOnline', () => { expect(isValidBrowserOnline()).toBe(false); }); + + it('returns FALSE in a worker scope whose navigator identifies a server runtime (e.g. Cloudflare Workers)', () => { + // Cloudflare Workers do not implement `navigator.onLine`, so without the server-runtime + // exclusion this would fall into the "onLine is not a boolean -> assume online" branch. + mockServiceWorkerScope(); + userAgentGetter.mockReturnValue('Cloudflare-Workers'); + webdriverGetter.mockReturnValue(false); + onLineGetter.mockReturnValue(undefined); + + expect(isValidBrowserOnline()).toBe(false); + }); }); diff --git a/packages/shared/src/browser.ts b/packages/shared/src/browser.ts index a129de01366..be8eaa36855 100644 --- a/packages/shared/src/browser.ts +++ b/packages/shared/src/browser.ts @@ -49,6 +49,17 @@ export function userAgentIsRobot(userAgent: string): boolean { return !userAgent ? false : botAgentRegex.test(userAgent); } +/** + * Server-side runtimes with worker-like globals self-identify in `navigator.userAgent` + * (`Cloudflare-Workers`, `Node.js/24`, `Deno/2.5.0`, `Bun/1.3.9`). Today workerd's `self` + * does not satisfy `instanceof WorkerGlobalScope` (even though it exposes the constructor), + * so the scope gate alone happens to exclude it, but that is an implementation detail of + * workerd's prototype chain, not a guarantee. Excluding self-identified server runtimes by + * user agent keeps these heuristics server-false even if such a runtime becomes fully + * spec-compliant about its worker scope. + */ +const serverRuntimeUserAgentRegex = /^(Cloudflare-Workers|Node\.js|Deno|Bun)\b/i; + /** * Resolves the `Navigator` object from either the DOM `window` (standard browsers) * or a Web/Service Worker global scope. An MV3 extension background service worker @@ -74,7 +85,8 @@ function getNavigator(): Navigator | null { if ( typeof workerScope.WorkerGlobalScope === 'function' && workerScope.self instanceof workerScope.WorkerGlobalScope && - workerScope.self.navigator + workerScope.self.navigator && + !serverRuntimeUserAgentRegex.test(workerScope.self.navigator.userAgent ?? '') ) { return workerScope.self.navigator; }