Skip to content

Commit 8744728

Browse files
authored
fix(shared): exclude server runtimes from the worker navigator fallback (#8840)
1 parent fa23ad8 commit 8744728

3 files changed

Lines changed: 65 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/shared': patch
3+
---
4+
5+
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.

packages/shared/src/__tests__/browser.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,42 @@ describe('isValidBrowser', () => {
7777
expect(isValidBrowser()).toBe(true);
7878
});
7979

80+
it('returns false in a worker scope whose navigator identifies a server runtime (e.g. Cloudflare Workers)', () => {
81+
// Today workerd's `self` fails the `instanceof WorkerGlobalScope` gate, but that is a
82+
// quirk of its prototype chain. This simulates a spec-compliant workerd: full worker
83+
// scope, navigator present, but the user agent self-identifies as a server runtime.
84+
mockServiceWorkerScope();
85+
userAgentGetter.mockReturnValue('Cloudflare-Workers');
86+
webdriverGetter.mockReturnValue(false);
87+
88+
expect(isValidBrowser()).toBe(false);
89+
});
90+
91+
it.each(['Node.js/24', 'Deno/2.5.0', 'Bun/1.3.9'])(
92+
'returns false in a worker scope whose navigator identifies the %s server runtime',
93+
userAgent => {
94+
mockServiceWorkerScope();
95+
userAgentGetter.mockReturnValue(userAgent);
96+
webdriverGetter.mockReturnValue(false);
97+
98+
expect(isValidBrowser()).toBe(false);
99+
},
100+
);
101+
102+
it('returns false when WorkerGlobalScope exists but self is not an instance of it (e.g. workerd today)', () => {
103+
// workerd exposes the WorkerGlobalScope constructor without linking `self` to its
104+
// prototype chain, so the instanceof gate must reject it even with a browser-like UA.
105+
vi.stubGlobal('WorkerGlobalScope', class {});
106+
vi.stubGlobal('self', {
107+
navigator: { userAgent: 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36', onLine: true, webdriver: false },
108+
});
109+
const windowSpy = vi.spyOn(global, 'window', 'get');
110+
// @ts-ignore - Test
111+
windowSpy.mockReturnValue(undefined);
112+
113+
expect(isValidBrowser()).toBe(false);
114+
});
115+
80116
it('returns true if in browser, navigator is not a bot, and webdriver is not enabled', () => {
81117
userAgentGetter.mockReturnValue(
82118
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/109.0',
@@ -275,4 +311,15 @@ describe('isValidBrowserOnline', () => {
275311

276312
expect(isValidBrowserOnline()).toBe(false);
277313
});
314+
315+
it('returns FALSE in a worker scope whose navigator identifies a server runtime (e.g. Cloudflare Workers)', () => {
316+
// Cloudflare Workers do not implement `navigator.onLine`, so without the server-runtime
317+
// exclusion this would fall into the "onLine is not a boolean -> assume online" branch.
318+
mockServiceWorkerScope();
319+
userAgentGetter.mockReturnValue('Cloudflare-Workers');
320+
webdriverGetter.mockReturnValue(false);
321+
onLineGetter.mockReturnValue(undefined);
322+
323+
expect(isValidBrowserOnline()).toBe(false);
324+
});
278325
});

packages/shared/src/browser.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ export function userAgentIsRobot(userAgent: string): boolean {
4949
return !userAgent ? false : botAgentRegex.test(userAgent);
5050
}
5151

52+
/**
53+
* Server-side runtimes with worker-like globals self-identify in `navigator.userAgent`
54+
* (`Cloudflare-Workers`, `Node.js/24`, `Deno/2.5.0`, `Bun/1.3.9`). Today workerd's `self`
55+
* does not satisfy `instanceof WorkerGlobalScope` (even though it exposes the constructor),
56+
* so the scope gate alone happens to exclude it, but that is an implementation detail of
57+
* workerd's prototype chain, not a guarantee. Excluding self-identified server runtimes by
58+
* user agent keeps these heuristics server-false even if such a runtime becomes fully
59+
* spec-compliant about its worker scope.
60+
*/
61+
const serverRuntimeUserAgentRegex = /^(Cloudflare-Workers|Node\.js|Deno|Bun)\b/i;
62+
5263
/**
5364
* Resolves the `Navigator` object from either the DOM `window` (standard browsers)
5465
* or a Web/Service Worker global scope. An MV3 extension background service worker
@@ -74,7 +85,8 @@ function getNavigator(): Navigator | null {
7485
if (
7586
typeof workerScope.WorkerGlobalScope === 'function' &&
7687
workerScope.self instanceof workerScope.WorkerGlobalScope &&
77-
workerScope.self.navigator
88+
workerScope.self.navigator &&
89+
!serverRuntimeUserAgentRegex.test(workerScope.self.navigator.userAgent ?? '')
7890
) {
7991
return workerScope.self.navigator;
8092
}

0 commit comments

Comments
 (0)