From 8c69eeb272a6197e702596f13079741b7058be7f Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 13 May 2026 23:06:56 -0500 Subject: [PATCH 1/6] chore(shared): replace telemetry postinstall with runtime notice Move the one-time telemetry disclosure off of npm lifecycle scripts and into the TelemetryCollector itself. Surfacing it at runtime instead of install time keeps @clerk/shared free of postinstall code, drops the std-env dependency, and only shows the notice in environments where telemetry actually fires. The Node persistence marker reuses the prior env-paths location, so users who already saw the notice via postinstall will not see it again. Resolves SDK-84. --- .../sdk-84-remove-telemetry-postinstall.md | 5 + packages/shared/package.json | 7 +- packages/shared/scripts/postinstall.mjs | 77 ------ .../src/__tests__/telemetry.notice.spec.ts | 129 ++++++++++ packages/shared/src/telemetry/collector.ts | 6 + packages/shared/src/telemetry/notice.ts | 224 ++++++++++++++++++ pnpm-lock.yaml | 3 - 7 files changed, 366 insertions(+), 85 deletions(-) create mode 100644 .changeset/sdk-84-remove-telemetry-postinstall.md delete mode 100755 packages/shared/scripts/postinstall.mjs create mode 100644 packages/shared/src/__tests__/telemetry.notice.spec.ts create mode 100644 packages/shared/src/telemetry/notice.ts diff --git a/.changeset/sdk-84-remove-telemetry-postinstall.md b/.changeset/sdk-84-remove-telemetry-postinstall.md new file mode 100644 index 00000000000..ba7cd8d0779 --- /dev/null +++ b/.changeset/sdk-84-remove-telemetry-postinstall.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced at runtime when the telemetry collector activates on a development instance, persisting the marker to the same location on Node so users who already saw the notice via `postinstall` will not see it again. This removes install-time code from the published package and drops the `std-env` dependency. diff --git a/packages/shared/package.json b/packages/shared/package.json index 3f61acd4f0c..c865719a744 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -120,8 +120,7 @@ } }, "files": [ - "dist", - "scripts" + "dist" ], "scripts": { "build": "tsdown", @@ -131,7 +130,6 @@ "dev:pub": "pnpm dev -- --env.publish", "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", - "postinstall": "node ./scripts/postinstall.mjs", "lint": "eslint src", "lint:attw": "attw --pack . --profile node16", "lint:publint": "publint", @@ -143,8 +141,7 @@ "@tanstack/query-core": "catalog:repo", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", - "js-cookie": "3.0.5", - "std-env": "^3.9.0" + "js-cookie": "3.0.5" }, "devDependencies": { "@base-org/account": "catalog:module-manager", diff --git a/packages/shared/scripts/postinstall.mjs b/packages/shared/scripts/postinstall.mjs deleted file mode 100755 index cafc989333c..00000000000 --- a/packages/shared/scripts/postinstall.mjs +++ /dev/null @@ -1,77 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; - -import { isCI } from 'std-env'; - -// If we make significant changes to how telemetry is collected in the future, bump this version. -const TELEMETRY_NOTICE_VERSION = '1'; - -function telemetryNotice() { - console.log(`Attention: Clerk now collects telemetry data from its SDKs when connected to development instances.`); - console.log(`The data collected is used to inform Clerk's product roadmap.`); - console.log( - `To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry.`, - ); - console.log(''); -} - -// Adapted from https://github.com/sindresorhus/env-paths -function getConfigDir(name) { - const homedir = os.homedir(); - const macos = () => path.join(homedir, 'Library', 'Preferences', name); - const win = () => { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const { APPDATA = path.join(homedir, 'AppData', 'Roaming') } = process.env; - return path.join(APPDATA, name, 'Config'); - }; - const linux = () => { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const { XDG_CONFIG_HOME = path.join(homedir, '.config') } = process.env; - return path.join(XDG_CONFIG_HOME, name); - }; - switch (process.platform) { - case 'darwin': - return macos(); - case 'win32': - return win(); - default: - return linux(); - } -} - -async function notifyAboutTelemetry() { - const configDir = getConfigDir('clerk'); - const configFile = path.join(configDir, 'config.json'); - - await fs.mkdir(configDir, { recursive: true }); - - let config = {}; - try { - config = JSON.parse(await fs.readFile(configFile, 'utf8')); - } catch (err) { - // File can't be read and parsed, continue - } - - if (parseInt(config.telemetryNoticeVersion, 10) >= TELEMETRY_NOTICE_VERSION) { - return; - } - - config.telemetryNoticeVersion = TELEMETRY_NOTICE_VERSION; - - if (!isCI) { - telemetryNotice(); - } - - await fs.writeFile(configFile, JSON.stringify(config, null, '\t')); -} - -async function main() { - try { - await notifyAboutTelemetry(); - } catch { - // Do nothing, we _really_ don't want to log errors during install. - } -} - -main(); diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts new file mode 100644 index 00000000000..4034d50b045 --- /dev/null +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { __resetTelemetryNoticeForTests, maybeShowTelemetryNotice } from '../telemetry/notice'; + +const STORAGE_KEY = 'clerk_telemetry_notice_v1'; +const CI_VARS = [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'BUILD_NUMBER', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TF_BUILD', + 'DRONE', + 'CODEBUILD_BUILD_ID', +]; + +function clearCIEnv() { + for (const name of CI_VARS) { + delete process.env[name]; + } +} + +describe('maybeShowTelemetryNotice', () => { + let logSpy: ReturnType; + let originalCIEnv: Record; + + beforeEach(() => { + originalCIEnv = Object.fromEntries(CI_VARS.map(name => [name, process.env[name]])); + clearCIEnv(); + globalThis.localStorage.clear(); + __resetTelemetryNoticeForTests(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + for (const [name, value] of Object.entries(originalCIEnv)) { + if (typeof value === 'string') { + process.env[name] = value; + } else { + delete process.env[name]; + } + } + }); + + test('prints the disclosure once and persists the marker', async () => { + await maybeShowTelemetryNotice(); + + expect(logSpy).toHaveBeenCalled(); + const printed = logSpy.mock.calls.map(call => String(call[0])).join('\n'); + expect(printed).toMatch(/Clerk collects telemetry/); + expect(printed).toMatch(/clerk\.com\/docs\/telemetry/); + expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBe('1'); + }); + + test('does not print again when the marker is already set', async () => { + globalThis.localStorage.setItem(STORAGE_KEY, '1'); + + await maybeShowTelemetryNotice(); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('skips entirely when skip:true is passed', async () => { + await maybeShowTelemetryNotice({ skip: true }); + + expect(logSpy).not.toHaveBeenCalled(); + expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + test('skips when a CI env var is set', async () => { + // eslint-disable-next-line turbo/no-undeclared-env-vars + process.env.CI = 'true'; + + await maybeShowTelemetryNotice(); + + expect(logSpy).not.toHaveBeenCalled(); + expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + test.each(CI_VARS)('skips when %s is set', async name => { + process.env[name] = '1'; + + await maybeShowTelemetryNotice(); + + expect(logSpy).not.toHaveBeenCalled(); + }); + + test('skips when navigator.webdriver is true', async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(window.navigator, 'webdriver'); + Object.defineProperty(window.navigator, 'webdriver', { configurable: true, value: true }); + + try { + await maybeShowTelemetryNotice(); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + if (originalDescriptor) { + Object.defineProperty(window.navigator, 'webdriver', originalDescriptor); + } else { + Object.defineProperty(window.navigator, 'webdriver', { configurable: true, value: false }); + } + } + }); + + test('dedupes concurrent calls within a single process', async () => { + await Promise.all([ + maybeShowTelemetryNotice(), + maybeShowTelemetryNotice(), + maybeShowTelemetryNotice(), + ]); + + // Notice consists of three lines + an empty trailing newline; assert disclosure was printed exactly once. + const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); + expect(disclosureCalls).toHaveLength(1); + }); + + test('does not throw if localStorage.setItem fails', async () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota exceeded'); + }); + + await expect(maybeShowTelemetryNotice()).resolves.toBeUndefined(); + setItemSpy.mockRestore(); + }); +}); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index e07ace47334..32f9853d0ac 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -20,6 +20,7 @@ import type { TelemetryLogEntry, } from '../types'; import { isTruthy } from '../underscore'; +import { maybeShowTelemetryNotice } from './notice'; import { InMemoryThrottlerCache, LocalStorageThrottlerCache, TelemetryEventThrottler } from './throttler'; import type { TelemetryCollectorOptions } from './types'; @@ -145,6 +146,11 @@ export class TelemetryCollector implements TelemetryCollectorInterface { ? new LocalStorageThrottlerCache() : new InMemoryThrottlerCache(); this.#eventThrottler = new TelemetryEventThrottler(cache); + + // Surface the one-time telemetry disclosure at runtime instead of via a postinstall script. + // Gated on `isEnabled` so users who opted out (or are not on a development instance) are not + // shown a notice for collection that will never happen. + void maybeShowTelemetryNotice({ skip: !this.isEnabled }); } get isEnabled(): boolean { diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts new file mode 100644 index 00000000000..6fb1a83d91c --- /dev/null +++ b/packages/shared/src/telemetry/notice.ts @@ -0,0 +1,224 @@ +/** + * One-time runtime disclosure that Clerk collects telemetry from development instances. + * + * This module replaces the previous `postinstall` script that ran on every install of + * `@clerk/shared`. Running disclosure at runtime (rather than via npm lifecycle scripts) + * keeps published packages free of install-time code, which is a common supply-chain + * concern, and only surfaces the notice in environments where telemetry actually fires. + * + * The notice is shown at most once per machine. The marker is persisted to: + * - `localStorage` in browsers + * - the same env-paths style config file the previous postinstall wrote to on Node, + * so users who already saw the notice via postinstall do not see it again + * - nowhere in environments without either (Workers, edge runtimes); the notice is + * simply skipped there + * + * All work is wrapped in try/catch. Failure to display or persist the notice must never + * affect the SDK. + */ + +import { isTruthy } from '../underscore'; + +const TELEMETRY_NOTICE_VERSION = 1; +const STORAGE_KEY = 'clerk_telemetry_notice_v1'; +const CONFIG_DIR_NAME = 'clerk'; +const CONFIG_FILE_NAME = 'config.json'; + +const NOTICE_LINES = [ + 'Attention: Clerk collects telemetry data from its SDKs when connected to development instances.', + "The data collected is used to inform Clerk's product roadmap.", + 'To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry.', +]; + +interface NoticeStore { + hasSeen(): Promise; + markSeen(): Promise; +} + +const CI_ENV_VARS = [ + 'CI', + 'CONTINUOUS_INTEGRATION', + 'BUILD_NUMBER', + 'GITHUB_ACTIONS', + 'GITLAB_CI', + 'CIRCLECI', + 'TRAVIS', + 'BUILDKITE', + 'JENKINS_URL', + 'TF_BUILD', + 'DRONE', + 'CODEBUILD_BUILD_ID', +]; + +function isCI(): boolean { + if (typeof process === 'undefined' || !process.env) { + return false; + } + return CI_ENV_VARS.some(name => isTruthy(process.env[name])); +} + +function isHeadlessBrowser(): boolean { + return typeof window !== 'undefined' && Boolean(window?.navigator?.webdriver); +} + +function hasUsableLocalStorage(): boolean { + try { + return typeof globalThis !== 'undefined' && typeof globalThis.localStorage !== 'undefined'; + } catch { + return false; + } +} + +class BrowserNoticeStore implements NoticeStore { + async hasSeen(): Promise { + const value = globalThis.localStorage.getItem(STORAGE_KEY); + return parseInt(value ?? '0', 10) >= TELEMETRY_NOTICE_VERSION; + } + + async markSeen(): Promise { + globalThis.localStorage.setItem(STORAGE_KEY, String(TELEMETRY_NOTICE_VERSION)); + } +} + +class NodeNoticeStore implements NoticeStore { + #pathPromise: Promise<{ dir: string; file: string } | null> | null = null; + + async #getPaths(): Promise<{ dir: string; file: string } | null> { + if (!this.#pathPromise) { + this.#pathPromise = (async () => { + try { + const os = await import('node:os'); + const path = await import('node:path'); + const homedir = os.homedir(); + let dir: string; + switch (process.platform) { + case 'darwin': + dir = path.join(homedir, 'Library', 'Preferences', CONFIG_DIR_NAME); + break; + case 'win32': { + // eslint-disable-next-line turbo/no-undeclared-env-vars + const appData = process.env.APPDATA ?? path.join(homedir, 'AppData', 'Roaming'); + dir = path.join(appData, CONFIG_DIR_NAME, 'Config'); + break; + } + default: { + // eslint-disable-next-line turbo/no-undeclared-env-vars + const xdg = process.env.XDG_CONFIG_HOME ?? path.join(homedir, '.config'); + dir = path.join(xdg, CONFIG_DIR_NAME); + } + } + return { dir, file: path.join(dir, CONFIG_FILE_NAME) }; + } catch { + return null; + } + })(); + } + return this.#pathPromise; + } + + async hasSeen(): Promise { + const paths = await this.#getPaths(); + if (!paths) { + return false; + } + try { + const fs = await import('node:fs/promises'); + const raw = await fs.readFile(paths.file, 'utf8'); + const parsed = JSON.parse(raw) as { telemetryNoticeVersion?: string | number }; + return parseInt(String(parsed.telemetryNoticeVersion ?? '0'), 10) >= TELEMETRY_NOTICE_VERSION; + } catch { + return false; + } + } + + async markSeen(): Promise { + const paths = await this.#getPaths(); + if (!paths) { + return; + } + const fs = await import('node:fs/promises'); + await fs.mkdir(paths.dir, { recursive: true }); + let existing: Record = {}; + try { + existing = JSON.parse(await fs.readFile(paths.file, 'utf8')) as Record; + } catch { + // file missing or unreadable + } + existing.telemetryNoticeVersion = TELEMETRY_NOTICE_VERSION; + await fs.writeFile(paths.file, JSON.stringify(existing, null, '\t')); + } +} + +function pickStore(): NoticeStore | null { + if (hasUsableLocalStorage()) { + return new BrowserNoticeStore(); + } + if (typeof process !== 'undefined' && process.versions?.node) { + return new NodeNoticeStore(); + } + return null; +} + +function printNotice(): void { + if (typeof console === 'undefined' || typeof console.log !== 'function') { + return; + } + for (const line of NOTICE_LINES) { + console.log(line); + } + console.log(''); +} + +export type MaybeShowTelemetryNoticeOptions = { + /** + * Skip the notice entirely. Used when the caller has already determined that no + * telemetry will be sent (e.g. opt-out, non-development instance), in which case + * there is nothing to disclose. + */ + skip?: boolean; +}; + +let inFlight: Promise | null = null; + +/** + * Display the one-time telemetry disclosure if it has not been shown on this machine. + * + * Safe to call multiple times: subsequent calls within the same process are deduped, + * and the persistence marker prevents re-display across processes. Never throws. + */ +export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): Promise { + if (options.skip) { + return Promise.resolve(); + } + if (inFlight) { + return inFlight; + } + inFlight = (async () => { + try { + if (isCI() || isHeadlessBrowser()) { + return; + } + const store = pickStore(); + if (!store) { + return; + } + if (await store.hasSeen()) { + return; + } + printNotice(); + await store.markSeen(); + } catch { + // never let disclosure break the SDK + } + })(); + return inFlight; +} + +/** + * Test-only: reset the in-process dedupe so the next call re-runs the gating logic. + * + * @internal + */ +export function __resetTelemetryNoticeForTests(): void { + inFlight = null; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8073536fa5f..56b9bf506f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -832,9 +832,6 @@ importers: react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) - std-env: - specifier: ^3.9.0 - version: 3.10.0 devDependencies: '@base-org/account': specifier: catalog:module-manager From 6592f972e3803c831c12e37a1dd434c9ea18fb2a Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 May 2026 14:15:17 -0500 Subject: [PATCH 2/6] fix(shared): evade bundler static analysis for node: imports @clerk/ui re-bundles @clerk/shared with Rspack, which does not handle bare `node:` URI imports even when they are only reachable on a Node runtime. Route the dynamic imports through a `new Function('id', 'return import(id)')` indirection so the module specifiers are no longer visible to static analysis. Also apply prettier to the new spec file. --- .../src/__tests__/telemetry.notice.spec.ts | 6 +----- packages/shared/src/telemetry/notice.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts index 4034d50b045..ed7ffc1db6e 100644 --- a/packages/shared/src/__tests__/telemetry.notice.spec.ts +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -107,11 +107,7 @@ describe('maybeShowTelemetryNotice', () => { }); test('dedupes concurrent calls within a single process', async () => { - await Promise.all([ - maybeShowTelemetryNotice(), - maybeShowTelemetryNotice(), - maybeShowTelemetryNotice(), - ]); + await Promise.all([maybeShowTelemetryNotice(), maybeShowTelemetryNotice(), maybeShowTelemetryNotice()]); // Notice consists of three lines + an empty trailing newline; assert disclosure was printed exactly once. const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts index 6fb1a83d91c..8510e69643a 100644 --- a/packages/shared/src/telemetry/notice.ts +++ b/packages/shared/src/telemetry/notice.ts @@ -87,8 +87,8 @@ class NodeNoticeStore implements NoticeStore { if (!this.#pathPromise) { this.#pathPromise = (async () => { try { - const os = await import('node:os'); - const path = await import('node:path'); + const os = await importNodeModule('node:os'); + const path = await importNodeModule('node:path'); const homedir = os.homedir(); let dir: string; switch (process.platform) { @@ -122,7 +122,7 @@ class NodeNoticeStore implements NoticeStore { return false; } try { - const fs = await import('node:fs/promises'); + const fs = await importNodeModule('node:fs/promises'); const raw = await fs.readFile(paths.file, 'utf8'); const parsed = JSON.parse(raw) as { telemetryNoticeVersion?: string | number }; return parseInt(String(parsed.telemetryNoticeVersion ?? '0'), 10) >= TELEMETRY_NOTICE_VERSION; @@ -136,7 +136,7 @@ class NodeNoticeStore implements NoticeStore { if (!paths) { return; } - const fs = await import('node:fs/promises'); + const fs = await importNodeModule('node:fs/promises'); await fs.mkdir(paths.dir, { recursive: true }); let existing: Record = {}; try { @@ -149,6 +149,14 @@ class NodeNoticeStore implements NoticeStore { } } +/** + * Loads a Node built-in module without exposing the import to static bundler analysis. + * Bundlers that target the browser (webpack, Rspack) would otherwise fail to compile the + * `node:fs/promises` / `node:os` / `node:path` literals even though the import is only + * reachable in a Node runtime. + */ +const importNodeModule = new Function('id', 'return import(id)') as (id: string) => Promise; + function pickStore(): NoticeStore | null { if (hasUsableLocalStorage()) { return new BrowserNoticeStore(); From 8d234668b1502492a1e1708accf7dceef7f99ad0 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 May 2026 14:35:12 -0500 Subject: [PATCH 3/6] fix(shared): drop fs persistence and new Function from telemetry notice Next.js Edge Runtime forbids `new Function`/`eval` even in unreachable code, so the dynamic-import-evasion trick used to hide `node:` module specifiers from webpack tripped the edge analyzer when @clerk/shared was reached through @clerk/nextjs middleware. Replace the Node config-file persistence with a `globalThis` Symbol flag (browser still uses localStorage). The notice now shows at most once per process on Node and once per machine in the browser, with no filesystem access and no dynamic-code APIs anywhere in the bundle. --- .../sdk-84-remove-telemetry-postinstall.md | 2 +- .../src/__tests__/telemetry.notice.spec.ts | 35 ++-- packages/shared/src/telemetry/collector.ts | 2 +- packages/shared/src/telemetry/notice.ts | 168 ++++-------------- 4 files changed, 57 insertions(+), 150 deletions(-) diff --git a/.changeset/sdk-84-remove-telemetry-postinstall.md b/.changeset/sdk-84-remove-telemetry-postinstall.md index ba7cd8d0779..576204629f9 100644 --- a/.changeset/sdk-84-remove-telemetry-postinstall.md +++ b/.changeset/sdk-84-remove-telemetry-postinstall.md @@ -2,4 +2,4 @@ '@clerk/shared': patch --- -Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced at runtime when the telemetry collector activates on a development instance, persisting the marker to the same location on Node so users who already saw the notice via `postinstall` will not see it again. This removes install-time code from the published package and drops the `std-env` dependency. +Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced at runtime when the telemetry collector activates on a development instance. The browser uses `localStorage` so the notice persists across page loads; Node uses a `globalThis` flag so it shows at most once per process. This removes install-time code from the published package and drops the `std-env` dependency. diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts index ed7ffc1db6e..b5884a492a2 100644 --- a/packages/shared/src/__tests__/telemetry.notice.spec.ts +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -47,8 +47,8 @@ describe('maybeShowTelemetryNotice', () => { } }); - test('prints the disclosure once and persists the marker', async () => { - await maybeShowTelemetryNotice(); + test('prints the disclosure once and persists the marker', () => { + maybeShowTelemetryNotice(); expect(logSpy).toHaveBeenCalled(); const printed = logSpy.mock.calls.map(call => String(call[0])).join('\n'); @@ -57,45 +57,45 @@ describe('maybeShowTelemetryNotice', () => { expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBe('1'); }); - test('does not print again when the marker is already set', async () => { + test('does not print again when the marker is already set', () => { globalThis.localStorage.setItem(STORAGE_KEY, '1'); - await maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); }); - test('skips entirely when skip:true is passed', async () => { - await maybeShowTelemetryNotice({ skip: true }); + test('skips entirely when skip:true is passed', () => { + maybeShowTelemetryNotice({ skip: true }); expect(logSpy).not.toHaveBeenCalled(); expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); }); - test('skips when a CI env var is set', async () => { + test('skips when a CI env var is set', () => { // eslint-disable-next-line turbo/no-undeclared-env-vars process.env.CI = 'true'; - await maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); }); - test.each(CI_VARS)('skips when %s is set', async name => { + test.each(CI_VARS)('skips when %s is set', name => { process.env[name] = '1'; - await maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); }); - test('skips when navigator.webdriver is true', async () => { + test('skips when navigator.webdriver is true', () => { const originalDescriptor = Object.getOwnPropertyDescriptor(window.navigator, 'webdriver'); Object.defineProperty(window.navigator, 'webdriver', { configurable: true, value: true }); try { - await maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); } finally { if (originalDescriptor) { @@ -106,20 +106,21 @@ describe('maybeShowTelemetryNotice', () => { } }); - test('dedupes concurrent calls within a single process', async () => { - await Promise.all([maybeShowTelemetryNotice(), maybeShowTelemetryNotice(), maybeShowTelemetryNotice()]); + test('does not print again when called multiple times in the same process', () => { + maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); - // Notice consists of three lines + an empty trailing newline; assert disclosure was printed exactly once. const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); expect(disclosureCalls).toHaveLength(1); }); - test('does not throw if localStorage.setItem fails', async () => { + test('does not throw if localStorage.setItem fails', () => { const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota exceeded'); }); - await expect(maybeShowTelemetryNotice()).resolves.toBeUndefined(); + expect(() => maybeShowTelemetryNotice()).not.toThrow(); setItemSpy.mockRestore(); }); }); diff --git a/packages/shared/src/telemetry/collector.ts b/packages/shared/src/telemetry/collector.ts index 32f9853d0ac..d798890f39c 100644 --- a/packages/shared/src/telemetry/collector.ts +++ b/packages/shared/src/telemetry/collector.ts @@ -150,7 +150,7 @@ export class TelemetryCollector implements TelemetryCollectorInterface { // Surface the one-time telemetry disclosure at runtime instead of via a postinstall script. // Gated on `isEnabled` so users who opted out (or are not on a development instance) are not // shown a notice for collection that will never happen. - void maybeShowTelemetryNotice({ skip: !this.isEnabled }); + maybeShowTelemetryNotice({ skip: !this.isEnabled }); } get isEnabled(): boolean { diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts index 8510e69643a..0e8d007d8ed 100644 --- a/packages/shared/src/telemetry/notice.ts +++ b/packages/shared/src/telemetry/notice.ts @@ -6,12 +6,13 @@ * keeps published packages free of install-time code, which is a common supply-chain * concern, and only surfaces the notice in environments where telemetry actually fires. * - * The notice is shown at most once per machine. The marker is persisted to: - * - `localStorage` in browsers - * - the same env-paths style config file the previous postinstall wrote to on Node, - * so users who already saw the notice via postinstall do not see it again - * - nowhere in environments without either (Workers, edge runtimes); the notice is - * simply skipped there + * Persistence: + * - In browsers, `localStorage` keeps the notice from re-displaying across reloads. + * - In Node and other JS runtimes, a `globalThis` Symbol flag keeps it from + * re-displaying within the same process (this also survives Next.js HMR module + * reloads, since `globalThis` is shared). No filesystem access is performed so the + * module remains safe to bundle for Edge Runtime, Workers, and other restricted + * environments. * * All work is wrapped in try/catch. Failure to display or persist the notice must never * affect the SDK. @@ -19,10 +20,8 @@ import { isTruthy } from '../underscore'; -const TELEMETRY_NOTICE_VERSION = 1; const STORAGE_KEY = 'clerk_telemetry_notice_v1'; -const CONFIG_DIR_NAME = 'clerk'; -const CONFIG_FILE_NAME = 'config.json'; +const PROCESS_FLAG = Symbol.for('@clerk/shared.telemetryNoticeShown'); const NOTICE_LINES = [ 'Attention: Clerk collects telemetry data from its SDKs when connected to development instances.', @@ -30,11 +29,6 @@ const NOTICE_LINES = [ 'To learn more, including how to opt-out from the telemetry program, visit: https://clerk.com/docs/telemetry.', ]; -interface NoticeStore { - hasSeen(): Promise; - markSeen(): Promise; -} - const CI_ENV_VARS = [ 'CI', 'CONTINUOUS_INTEGRATION', @@ -69,102 +63,27 @@ function hasUsableLocalStorage(): boolean { } } -class BrowserNoticeStore implements NoticeStore { - async hasSeen(): Promise { - const value = globalThis.localStorage.getItem(STORAGE_KEY); - return parseInt(value ?? '0', 10) >= TELEMETRY_NOTICE_VERSION; - } - - async markSeen(): Promise { - globalThis.localStorage.setItem(STORAGE_KEY, String(TELEMETRY_NOTICE_VERSION)); - } -} - -class NodeNoticeStore implements NoticeStore { - #pathPromise: Promise<{ dir: string; file: string } | null> | null = null; - - async #getPaths(): Promise<{ dir: string; file: string } | null> { - if (!this.#pathPromise) { - this.#pathPromise = (async () => { - try { - const os = await importNodeModule('node:os'); - const path = await importNodeModule('node:path'); - const homedir = os.homedir(); - let dir: string; - switch (process.platform) { - case 'darwin': - dir = path.join(homedir, 'Library', 'Preferences', CONFIG_DIR_NAME); - break; - case 'win32': { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const appData = process.env.APPDATA ?? path.join(homedir, 'AppData', 'Roaming'); - dir = path.join(appData, CONFIG_DIR_NAME, 'Config'); - break; - } - default: { - // eslint-disable-next-line turbo/no-undeclared-env-vars - const xdg = process.env.XDG_CONFIG_HOME ?? path.join(homedir, '.config'); - dir = path.join(xdg, CONFIG_DIR_NAME); - } - } - return { dir, file: path.join(dir, CONFIG_FILE_NAME) }; - } catch { - return null; - } - })(); - } - return this.#pathPromise; - } - - async hasSeen(): Promise { - const paths = await this.#getPaths(); - if (!paths) { - return false; - } +function hasSeen(): boolean { + if (hasUsableLocalStorage()) { try { - const fs = await importNodeModule('node:fs/promises'); - const raw = await fs.readFile(paths.file, 'utf8'); - const parsed = JSON.parse(raw) as { telemetryNoticeVersion?: string | number }; - return parseInt(String(parsed.telemetryNoticeVersion ?? '0'), 10) >= TELEMETRY_NOTICE_VERSION; + return globalThis.localStorage.getItem(STORAGE_KEY) === '1'; } catch { return false; } } + return Boolean((globalThis as Record)[PROCESS_FLAG]); +} - async markSeen(): Promise { - const paths = await this.#getPaths(); - if (!paths) { - return; - } - const fs = await importNodeModule('node:fs/promises'); - await fs.mkdir(paths.dir, { recursive: true }); - let existing: Record = {}; +function markSeen(): void { + if (hasUsableLocalStorage()) { try { - existing = JSON.parse(await fs.readFile(paths.file, 'utf8')) as Record; + globalThis.localStorage.setItem(STORAGE_KEY, '1'); + return; } catch { - // file missing or unreadable + // fall through to the in-process flag } - existing.telemetryNoticeVersion = TELEMETRY_NOTICE_VERSION; - await fs.writeFile(paths.file, JSON.stringify(existing, null, '\t')); } -} - -/** - * Loads a Node built-in module without exposing the import to static bundler analysis. - * Bundlers that target the browser (webpack, Rspack) would otherwise fail to compile the - * `node:fs/promises` / `node:os` / `node:path` literals even though the import is only - * reachable in a Node runtime. - */ -const importNodeModule = new Function('id', 'return import(id)') as (id: string) => Promise; - -function pickStore(): NoticeStore | null { - if (hasUsableLocalStorage()) { - return new BrowserNoticeStore(); - } - if (typeof process !== 'undefined' && process.versions?.node) { - return new NodeNoticeStore(); - } - return null; + (globalThis as Record)[PROCESS_FLAG] = true; } function printNotice(): void { @@ -186,47 +105,34 @@ export type MaybeShowTelemetryNoticeOptions = { skip?: boolean; }; -let inFlight: Promise | null = null; - /** - * Display the one-time telemetry disclosure if it has not been shown on this machine. - * - * Safe to call multiple times: subsequent calls within the same process are deduped, - * and the persistence marker prevents re-display across processes. Never throws. + * Display the one-time telemetry disclosure if it has not already been shown. Safe to + * call repeatedly: the browser-side marker prevents re-display across reloads, and the + * `globalThis` flag prevents re-display within the same Node process. Never throws. */ -export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): Promise { +export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): void { if (options.skip) { - return Promise.resolve(); - } - if (inFlight) { - return inFlight; + return; } - inFlight = (async () => { - try { - if (isCI() || isHeadlessBrowser()) { - return; - } - const store = pickStore(); - if (!store) { - return; - } - if (await store.hasSeen()) { - return; - } - printNotice(); - await store.markSeen(); - } catch { - // never let disclosure break the SDK + try { + if (isCI() || isHeadlessBrowser()) { + return; } - })(); - return inFlight; + if (hasSeen()) { + return; + } + printNotice(); + markSeen(); + } catch { + // never let disclosure break the SDK + } } /** - * Test-only: reset the in-process dedupe so the next call re-runs the gating logic. + * Test-only: clear the in-process flag so the next call re-runs the gating logic. * * @internal */ export function __resetTelemetryNoticeForTests(): void { - inFlight = null; + delete (globalThis as Record)[PROCESS_FLAG]; } From 2e0d12a0fcd4a07b753c52b3a8e75d034e087fc1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 May 2026 20:25:35 -0500 Subject: [PATCH 4/6] refactor(shared): scope telemetry notice to server-side only Browser consoles are observed by non-developers (QA, screenshots, demos), so adding another runtime warning there invites the same complaints we get about the existing dev-mode banner. Drop the browser surface entirely and emit the disclosure only on Node, matching the noise profile of the original postinstall (terminal-only, dev-eyes-only). Persistence simplifies to a single per-process globalThis flag; the localStorage path and the bug it had around getItem exceptions both go away. --- .../sdk-84-remove-telemetry-postinstall.md | 2 +- .../src/__tests__/telemetry.notice.spec.ts | 47 +++++--------- packages/shared/src/telemetry/notice.ts | 65 ++++++------------- 3 files changed, 39 insertions(+), 75 deletions(-) diff --git a/.changeset/sdk-84-remove-telemetry-postinstall.md b/.changeset/sdk-84-remove-telemetry-postinstall.md index 576204629f9..53bb56dd5b9 100644 --- a/.changeset/sdk-84-remove-telemetry-postinstall.md +++ b/.changeset/sdk-84-remove-telemetry-postinstall.md @@ -2,4 +2,4 @@ '@clerk/shared': patch --- -Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced at runtime when the telemetry collector activates on a development instance. The browser uses `localStorage` so the notice persists across page loads; Node uses a `globalThis` flag so it shows at most once per process. This removes install-time code from the published package and drops the `std-env` dependency. +Remove the `postinstall` lifecycle script that displayed the telemetry disclosure notice. The same one-time disclosure is now surfaced server-side at runtime when the telemetry collector activates on a development instance, deduped per process via a `globalThis` flag. The notice is intentionally not emitted in browser consoles to keep the noise profile in line with the original postinstall (terminal-only). This removes install-time code from the published package and drops the `std-env` dependency. diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts index b5884a492a2..e6994296059 100644 --- a/packages/shared/src/__tests__/telemetry.notice.spec.ts +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -1,8 +1,10 @@ +/** + * @vitest-environment node + */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { __resetTelemetryNoticeForTests, maybeShowTelemetryNotice } from '../telemetry/notice'; -const STORAGE_KEY = 'clerk_telemetry_notice_v1'; const CI_VARS = [ 'CI', 'CONTINUOUS_INTEGRATION', @@ -31,7 +33,6 @@ describe('maybeShowTelemetryNotice', () => { beforeEach(() => { originalCIEnv = Object.fromEntries(CI_VARS.map(name => [name, process.env[name]])); clearCIEnv(); - globalThis.localStorage.clear(); __resetTelemetryNoticeForTests(); logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); }); @@ -47,29 +48,28 @@ describe('maybeShowTelemetryNotice', () => { } }); - test('prints the disclosure once and persists the marker', () => { + test('prints the disclosure on Node', () => { maybeShowTelemetryNotice(); expect(logSpy).toHaveBeenCalled(); const printed = logSpy.mock.calls.map(call => String(call[0])).join('\n'); expect(printed).toMatch(/Clerk collects telemetry/); expect(printed).toMatch(/clerk\.com\/docs\/telemetry/); - expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBe('1'); }); - test('does not print again when the marker is already set', () => { - globalThis.localStorage.setItem(STORAGE_KEY, '1'); - + test('does not print again on subsequent calls in the same process', () => { + maybeShowTelemetryNotice(); + maybeShowTelemetryNotice(); maybeShowTelemetryNotice(); - expect(logSpy).not.toHaveBeenCalled(); + const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); + expect(disclosureCalls).toHaveLength(1); }); test('skips entirely when skip:true is passed', () => { maybeShowTelemetryNotice({ skip: true }); expect(logSpy).not.toHaveBeenCalled(); - expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); }); test('skips when a CI env var is set', () => { @@ -79,7 +79,6 @@ describe('maybeShowTelemetryNotice', () => { maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); - expect(globalThis.localStorage.getItem(STORAGE_KEY)).toBeNull(); }); test.each(CI_VARS)('skips when %s is set', name => { @@ -90,37 +89,27 @@ describe('maybeShowTelemetryNotice', () => { expect(logSpy).not.toHaveBeenCalled(); }); - test('skips when navigator.webdriver is true', () => { - const originalDescriptor = Object.getOwnPropertyDescriptor(window.navigator, 'webdriver'); - Object.defineProperty(window.navigator, 'webdriver', { configurable: true, value: true }); + test('skips in a browser-like environment', () => { + const original = (globalThis as { window?: unknown }).window; + (globalThis as { window?: unknown }).window = {}; try { maybeShowTelemetryNotice(); expect(logSpy).not.toHaveBeenCalled(); } finally { - if (originalDescriptor) { - Object.defineProperty(window.navigator, 'webdriver', originalDescriptor); + if (typeof original === 'undefined') { + delete (globalThis as { window?: unknown }).window; } else { - Object.defineProperty(window.navigator, 'webdriver', { configurable: true, value: false }); + (globalThis as { window?: unknown }).window = original; } } }); - test('does not print again when called multiple times in the same process', () => { - maybeShowTelemetryNotice(); - maybeShowTelemetryNotice(); - maybeShowTelemetryNotice(); - - const disclosureCalls = logSpy.mock.calls.filter(call => /Clerk collects telemetry/.test(String(call[0]))); - expect(disclosureCalls).toHaveLength(1); - }); - - test('does not throw if localStorage.setItem fails', () => { - const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('quota exceeded'); + test('does not throw if console.log fails', () => { + logSpy.mockImplementation(() => { + throw new Error('console broken'); }); expect(() => maybeShowTelemetryNotice()).not.toThrow(); - setItemSpy.mockRestore(); }); }); diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts index 0e8d007d8ed..27c77757c90 100644 --- a/packages/shared/src/telemetry/notice.ts +++ b/packages/shared/src/telemetry/notice.ts @@ -1,26 +1,22 @@ /** * One-time runtime disclosure that Clerk collects telemetry from development instances. * - * This module replaces the previous `postinstall` script that ran on every install of - * `@clerk/shared`. Running disclosure at runtime (rather than via npm lifecycle scripts) - * keeps published packages free of install-time code, which is a common supply-chain - * concern, and only surfaces the notice in environments where telemetry actually fires. + * Replaces the previous `postinstall` script. Disclosure is intentionally surfaced + * only on Node (server-side) so the noise profile matches the original postinstall + * (terminal-only, dev-eyes-only). Browser consoles are not used because they are + * frequently observed by non-developers (QA, screenshots, demos), and adding another + * console warning is a common source of customer complaints. * - * Persistence: - * - In browsers, `localStorage` keeps the notice from re-displaying across reloads. - * - In Node and other JS runtimes, a `globalThis` Symbol flag keeps it from - * re-displaying within the same process (this also survives Next.js HMR module - * reloads, since `globalThis` is shared). No filesystem access is performed so the - * module remains safe to bundle for Edge Runtime, Workers, and other restricted - * environments. + * Persistence is in-process via a `globalThis` Symbol, which survives Next.js HMR + * module reloads. No filesystem access, no `node:` imports, no dynamic-code APIs, so + * the module remains safe to bundle for Edge Runtime, Workers, and any browser path. * - * All work is wrapped in try/catch. Failure to display or persist the notice must never - * affect the SDK. + * All work is wrapped in try/catch. Failure to display the notice must never affect + * the SDK. */ import { isTruthy } from '../underscore'; -const STORAGE_KEY = 'clerk_telemetry_notice_v1'; const PROCESS_FLAG = Symbol.for('@clerk/shared.telemetryNoticeShown'); const NOTICE_LINES = [ @@ -44,6 +40,10 @@ const CI_ENV_VARS = [ 'CODEBUILD_BUILD_ID', ]; +function isNodeRuntime(): boolean { + return typeof window === 'undefined' && typeof process !== 'undefined' && Boolean(process.versions?.node); +} + function isCI(): boolean { if (typeof process === 'undefined' || !process.env) { return false; @@ -51,38 +51,11 @@ function isCI(): boolean { return CI_ENV_VARS.some(name => isTruthy(process.env[name])); } -function isHeadlessBrowser(): boolean { - return typeof window !== 'undefined' && Boolean(window?.navigator?.webdriver); -} - -function hasUsableLocalStorage(): boolean { - try { - return typeof globalThis !== 'undefined' && typeof globalThis.localStorage !== 'undefined'; - } catch { - return false; - } -} - function hasSeen(): boolean { - if (hasUsableLocalStorage()) { - try { - return globalThis.localStorage.getItem(STORAGE_KEY) === '1'; - } catch { - return false; - } - } return Boolean((globalThis as Record)[PROCESS_FLAG]); } function markSeen(): void { - if (hasUsableLocalStorage()) { - try { - globalThis.localStorage.setItem(STORAGE_KEY, '1'); - return; - } catch { - // fall through to the in-process flag - } - } (globalThis as Record)[PROCESS_FLAG] = true; } @@ -106,16 +79,18 @@ export type MaybeShowTelemetryNoticeOptions = { }; /** - * Display the one-time telemetry disclosure if it has not already been shown. Safe to - * call repeatedly: the browser-side marker prevents re-display across reloads, and the - * `globalThis` flag prevents re-display within the same Node process. Never throws. + * Display the one-time telemetry disclosure on Node if it has not already been shown + * in this process. Browser callers are silently skipped. Never throws. */ export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): void { if (options.skip) { return; } try { - if (isCI() || isHeadlessBrowser()) { + if (!isNodeRuntime()) { + return; + } + if (isCI()) { return; } if (hasSeen()) { From 554a19239541862aa5dea50b92be1a3161d277b1 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 May 2026 20:56:49 -0500 Subject: [PATCH 5/6] fix(shared): detect server runtime without reading process.versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Next.js Edge Runtime build-time analyzer flags any reachable read of process.versions even when guarded — this caused a warning when @clerk/backend was reached through middleware, which the next-build integration test asserts against. Detect Edge Runtime via its standard `EdgeRuntime` global instead, and use the absence of `window`+`EdgeRuntime` as the positive signal for "server runtime" without touching process.versions at all. --- .../src/__tests__/telemetry.notice.spec.ts | 11 +++++++++++ packages/shared/src/telemetry/notice.ts | 16 +++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/__tests__/telemetry.notice.spec.ts b/packages/shared/src/__tests__/telemetry.notice.spec.ts index e6994296059..b8ef35457b5 100644 --- a/packages/shared/src/__tests__/telemetry.notice.spec.ts +++ b/packages/shared/src/__tests__/telemetry.notice.spec.ts @@ -105,6 +105,17 @@ describe('maybeShowTelemetryNotice', () => { } }); + test('skips in Next.js Edge Runtime', () => { + (globalThis as { EdgeRuntime?: string }).EdgeRuntime = 'edge-runtime'; + + try { + maybeShowTelemetryNotice(); + expect(logSpy).not.toHaveBeenCalled(); + } finally { + delete (globalThis as { EdgeRuntime?: string }).EdgeRuntime; + } + }); + test('does not throw if console.log fails', () => { logSpy.mockImplementation(() => { throw new Error('console broken'); diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts index 27c77757c90..ad0a16ec69b 100644 --- a/packages/shared/src/telemetry/notice.ts +++ b/packages/shared/src/telemetry/notice.ts @@ -40,8 +40,14 @@ const CI_ENV_VARS = [ 'CODEBUILD_BUILD_ID', ]; -function isNodeRuntime(): boolean { - return typeof window === 'undefined' && typeof process !== 'undefined' && Boolean(process.versions?.node); +function isServerRuntime(): boolean { + // Skip in browsers. + if (typeof window !== 'undefined') return false; + // Skip in Next.js Edge Runtime, which exposes a global `EdgeRuntime` marker. We detect via + // this marker (rather than checking `process.versions`) because the Edge Runtime build-time + // analyzer flags any reachable read of `process.versions` even when it sits behind a guard. + if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return false; + return true; } function isCI(): boolean { @@ -79,15 +85,15 @@ export type MaybeShowTelemetryNoticeOptions = { }; /** - * Display the one-time telemetry disclosure on Node if it has not already been shown - * in this process. Browser callers are silently skipped. Never throws. + * Display the one-time telemetry disclosure on server runtimes if it has not already been + * shown in this process. Browser and Edge Runtime callers are silently skipped. Never throws. */ export function maybeShowTelemetryNotice(options: MaybeShowTelemetryNoticeOptions = {}): void { if (options.skip) { return; } try { - if (!isNodeRuntime()) { + if (!isServerRuntime()) { return; } if (isCI()) { From 9e5fe52fc5f943cd69eafac29c2c4d1e0cfc1d2a Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 14 May 2026 22:08:00 -0500 Subject: [PATCH 6/6] fix(shared): add curly braces to satisfy eslint curly rule --- packages/shared/src/telemetry/notice.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/telemetry/notice.ts b/packages/shared/src/telemetry/notice.ts index ad0a16ec69b..8efa5344b84 100644 --- a/packages/shared/src/telemetry/notice.ts +++ b/packages/shared/src/telemetry/notice.ts @@ -42,11 +42,15 @@ const CI_ENV_VARS = [ function isServerRuntime(): boolean { // Skip in browsers. - if (typeof window !== 'undefined') return false; + if (typeof window !== 'undefined') { + return false; + } // Skip in Next.js Edge Runtime, which exposes a global `EdgeRuntime` marker. We detect via // this marker (rather than checking `process.versions`) because the Edge Runtime build-time // analyzer flags any reachable read of `process.versions` even when it sits behind a guard. - if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') return false; + if (typeof (globalThis as { EdgeRuntime?: string }).EdgeRuntime !== 'undefined') { + return false; + } return true; }