diff --git a/docs/content/scripts/fathom-analytics.md b/docs/content/scripts/fathom-analytics.md index b90e1b52..2ef53593 100644 --- a/docs/content/scripts/fathom-analytics.md +++ b/docs/content/scripts/fathom-analytics.md @@ -16,6 +16,16 @@ links: ::script-docs :: +## Proxying is not supported + +Unlike most analytics integrations in Nuxt Scripts, Fathom **cannot** be proxied (`proxy: true`). + +Fathom's bot detection uses the connecting source IP address. When beacons are proxied, they reach Fathom from your server's IP (typically a datacenter), and Fathom's bot detection ignores `X-Forwarded-For` from arbitrary servers, so every visitor gets flagged as a bot. + +Fathom previously offered an official Custom Domain feature (CNAME to their infrastructure) for first-party hosting, but they [deprecated it in May 2023](https://usefathom.com/changelog/mar2023-firewall-settings) and there is no replacement. + +Bundling (`bundle: true`) **is** supported: the script is served from your origin, but beacons still go directly to `cdn.usefathom.com` from the browser so real client IPs reach Fathom's bot detection correctly. + ## Defaults - **Trigger**: Script will load when Nuxt is hydrated. diff --git a/packages/script/src/plugins/rewrite-ast.ts b/packages/script/src/plugins/rewrite-ast.ts index bd0c63ea..2e134eab 100644 --- a/packages/script/src/plugins/rewrite-ast.ts +++ b/packages/script/src/plugins/rewrite-ast.ts @@ -358,7 +358,9 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites // SDK patch: neutralize-domain-check // Matches `.indexOf("...domain...") < 0` and rewrites `< 0` to `< -1` - if (sdkPatches?.some(p => p.type === 'neutralize-domain-check') + const neutralizePatches = sdkPatches?.filter((p): p is { type: 'neutralize-domain-check', domain: string } => + p.type === 'neutralize-domain-check') + if (neutralizePatches?.length && node.type === 'BinaryExpression' && (node as any).operator === '<') { const left = (node as any).left @@ -372,7 +374,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites if (prop === 'indexOf' && left.arguments?.length === 1) { const arg = left.arguments[0] if (arg?.type === 'Literal' && typeof arg.value === 'string' - && rewrites.some(r => arg.value.includes(r.from))) { + && neutralizePatches.some(p => arg.value.includes(p.domain))) { s.overwrite(right.start, right.end, '-1') } } diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 466cc71c..cda30347 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -136,11 +136,12 @@ async function downloadScript(opts: { let size = 0 let fetched = false - // Use storage to cache the font data between builds - // Include proxy in cache key to differentiate proxied vs non-proxied versions - // Also include a hash of proxyRewrites content to handle different proxyPrefix values - const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : '' - const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}` + // Cache patched bundles under a separate prefix so they don't collide with + // raw bundles. Hash the rewrite/patch inputs so changes to either (different + // proxyPrefix, new sdkPatches domain, etc.) invalidate the cache. + const hasRewrites = !!(proxyRewrites?.length || sdkPatches?.length) + const rewriteHash = hasRewrites ? `-${ohash({ proxyRewrites, sdkPatches })}` : '' + const cacheKey = hasRewrites ? `bundle-patched:${filename.replace('.js', `${rewriteHash}.js`)}` : `bundle:${filename}` const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) if (shouldUseCache) { @@ -160,12 +161,14 @@ async function downloadScript(opts: { fetched = true await storage.setItemRaw(`bundle:${filename}`, res) - // Apply URL rewrites for proxy mode (AST-based at build time) - if (proxyRewrites?.length && res) { + // Apply AST rewrites at build time. Runs when either proxy rewrites are + // present (proxy mode) or bundle-only sdkPatches are configured (e.g. + // Fathom's neutralize-domain-check). + if (hasRewrites && res) { const content = res.toString('utf-8') - const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas }) + const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites ?? [], sdkPatches, { skipApiRewrites, neutralizeCanvas }) res = Buffer.from(rewritten, 'utf-8') - logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`) + logger.debug(`Rewrote ${proxyRewrites?.length ?? 0} URL patterns + ${sdkPatches?.length ?? 0} sdk patches in ${filename}`) } await storage.setItemRaw(cacheKey, res) @@ -466,8 +469,18 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti from: domain, to: `${options.proxyPrefix}/${domain}`, })) - const sdkPatches = proxyConfig?.sdkPatches + // Bundle-only SDK patches (independent of proxy). Used when bundling + // a script that needs neutralize-domain-check etc. but should keep + // sending requests directly to its origin (e.g. Fathom). + // When both are defined, proxyConfig.sdkPatches wins — proxy patches + // are typically tuned for the rewritten URL set and should take precedence. + const bundleConfig = typeof script?.bundle === 'object' ? script.bundle : undefined + const sdkPatches = proxyConfig?.sdkPatches ?? bundleConfig?.sdkPatches + // Skip API rewrites (sendBeacon/fetch/XHR/Image → __nuxtScripts.*) when: + // 1. Partytown is active (uses resolveUrl instead), OR + // 2. No proxy is active (no intercept plugin loaded — calls would crash) const skipApiRewrites = !!(registryKey && options.partytownScripts?.has(registryKey)) + || !proxyConfig // Gate canvas fingerprinting neutralization on the script's hardware privacy flag const neutralizeCanvas = proxyConfig?.privacy !== undefined && typeof proxyConfig.privacy === 'object' diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 37bc14a5..1c9a2364 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -116,7 +116,12 @@ export const registryMeta: RegistryScriptMeta[] = [ m('plausibleAnalytics', 'Plausible Analytics', 'analytics', 'useScriptPlausibleAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), m('cloudflareWebAnalytics', 'Cloudflare Web Analytics', 'analytics', 'useScriptCloudflareWebAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), m('posthog', 'PostHog', 'analytics', 'useScriptPostHog', { proxy: true }, PRIVACY_IP_ONLY), - m('fathomAnalytics', 'Fathom Analytics', 'analytics', 'useScriptFathomAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY), + // proxy intentionally off: proxied beacons reach Fathom from the server's IP + // (datacenter) and Fathom's bot detection ignores X-Forwarded-For, flagging + // every visitor as a bot. Bundle is supported via neutralize-domain-check — + // the script is served from the user's origin but beacons still go directly + // to cdn.usefathom.com so Fathom sees real client IPs. See nuxt/scripts#720. + m('fathomAnalytics', 'Fathom Analytics', 'analytics', 'useScriptFathomAnalytics', { bundle: true }, null), m('matomoAnalytics', 'Matomo Analytics', 'analytics', 'useScriptMatomoAnalytics', { proxy: true, partytown: true }, PRIVACY_IP_ONLY), m('rybbitAnalytics', 'Rybbit Analytics', 'analytics', 'useScriptRybbitAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), m('databuddyAnalytics', 'Databuddy Analytics', 'analytics', 'useScriptDatabuddyAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY), @@ -337,13 +342,13 @@ export async function registry(resolve?: (path: string) => Promise): Pro src: 'https://cdn.usefathom.com/script.js', category: 'analytics', envDefaults: { site: '' }, - bundle: true, - proxy: { - domains: ['cdn.usefathom.com', 'usefathom.com'], - privacy: PRIVACY_IP_ONLY, - sdkPatches: [{ type: 'neutralize-domain-check' }], + // Bundle without proxy: serve the script from the user's origin (faster + // load, ad-blocker resistant for domain-based blocking) but keep beacons + // pointed at cdn.usefathom.com via the neutralize-domain-check patch so + // Fathom sees real client IPs. Proxying is unsupported (see #720). + bundle: { + sdkPatches: [{ type: 'neutralize-domain-check', domain: 'cdn.usefathom.com' }], }, - partytown: { forwards: ['fathom', 'fathom.trackEvent', 'fathom.trackPageview'] }, }), def('matomoAnalytics', { schema: MatomoAnalyticsOptions, diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 12fc5f8d..4d01f6a8 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -380,6 +380,13 @@ export interface ScriptDomain { export interface BundleCapability { /** Custom URL resolution. If omitted, the script's `src` is used. */ resolve?: (options?: any) => string | false + /** + * AST-level SDK patches applied during bundling, independent of proxy. + * Use for scripts that need self-hosted detection neutralization but should + * still send beacons directly to the origin (e.g. Fathom, where proxying + * triggers bot detection but bundling is otherwise safe). + */ + sdkPatches?: SdkPatch[] } /** @@ -408,11 +415,11 @@ export interface ProxyCapability { export type SdkPatch /** * Neutralize self-hosted detection checks like `.indexOf("cdn.example.com") < 0`. - * When a script is proxied, its src no longer contains the original CDN domain, - * causing these checks to incorrectly detect "self-hosted" mode. + * When a script is bundled or proxied, its src no longer contains the original + * CDN domain, causing these checks to incorrectly detect "self-hosted" mode. * This patch makes such comparisons always evaluate to false. */ - = | { type: 'neutralize-domain-check' } + = | { type: 'neutralize-domain-check', domain: string } /** * Replace `.split("")[0]` patterns used by SDKs that derive * their API host from `document.currentScript.src`. When bundled, the script src diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index 21d58569..0242a9b3 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -647,8 +647,8 @@ describe('first-party privacy stripping', () => { 'redditPixel', // rdt('track', ...) triggers pixel fires 'plausibleAnalytics', // plausible() triggers fetch POST 'umamiAnalytics', // umami.track() triggers fetch POST - 'fathomAnalytics', // fathom.trackGoal() triggers beacon // cloudflareWebAnalytics — auto-engagement only, no CTA buttons + // fathomAnalytics — bundle/proxy disabled (Fathom bot-detection flags self-hosted/proxied traffic, see #720) ]) /** @@ -673,7 +673,6 @@ describe('first-party privacy stripping', () => { 'googleAnalytics', // scope-resolved AST rewrite for sendBeacon/fetch/XHR/Image 'snapchatPixel', // scope-resolved AST rewrite for sendBeacon/XHR // googleTagManager — uses createElement('script') injection, not interceptable via XHR/fetch/sendBeacon - 'fathomAnalytics', // bundled + self-hosted detection neutralized, sendBeacon/Image interception 'plausibleAnalytics', // bundled + auto-inject endpoint, sendBeacon interception (needs extension: 'local' + __plausible flag for headless) 'tiktokPixel', // AST rewrite for analytics.tiktok.com, sendBeacon/fetch interception // databuddyAnalytics — SDK doesn't fire events with demo clientId in test window @@ -910,13 +909,7 @@ describe('first-party privacy stripping', () => { }, { pre: preClickProxyCount, post: postClickProxyCount }) }, 30000) - it('fathomAnalytics', async () => { - const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('fathomAnalytics', '/fathom') - await assertCaptures('fathomAnalytics', captures, rawCaptures, proxyRequests, externalRequests, { - proxyPrefix: '/_scripts/p/fathom', - domains: ['usefathom.com'], - }, { pre: preClickProxyCount, post: postClickProxyCount }) - }, 30000) + // fathomAnalytics — bundle/proxy disabled in registry (see #720), script loads directly from CDN it('intercom', async () => { const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('intercom', '/intercom-test') diff --git a/test/unit/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts new file mode 100644 index 00000000..dada8cda --- /dev/null +++ b/test/unit/bundle-sdk-patches.test.ts @@ -0,0 +1,118 @@ +// Integration guard for bundle-only sdkPatches (nuxt/scripts#720 / Fathom). +// Exercises the full NuxtScriptBundleTransformer → downloadScript → +// rewriteScriptUrlsAST pipeline to prove the neutralize-domain-check patch is +// actually applied to the stored bundle. A prior regression gated the rewrite +// on proxyRewrites.length, so bundle-only patches were silently dropped while +// direct unit tests of rewriteScriptUrlsAST still passed. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import { hash } from 'ohash' +import { hasProtocol } from 'ufo' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' + +vi.mock('ohash', async (og) => { + const mod = await og() + return { ...mod, hash: vi.fn(mod.hash) } +}) +vi.mock('ufo', async (og) => { + const mod = await og() + return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) } +}) + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +vi.mock('@nuxt/kit', async (og) => { + const mod = await og() + const nuxt = { + options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } }, + hooks: { hook: vi.fn() }, + } + return { ...mod, useNuxt: () => nuxt, tryUseNuxt: () => nuxt } +}) + +vi.mocked(hasProtocol).mockImplementation(() => true) +vi.mocked(hash).mockImplementation(() => 'fathom-script') + +function mockUpstream(bytes: Buffer) { + fetchMock.mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(bytes), + headers: { get: () => null }, + _data: bytes, + } as any) +} + +async function runTransform(code: string, options: AssetBundlerTransformerOptions) { + const plugin = NuxtScriptBundleTransformer(options).vite() as any + await plugin.transform.handler.call({}, code, 'file.js') +} + +describe('bundle-only sdkPatches integration', () => { + const fathomLike = Buffer.from( + `(function(){var e=document.currentScript;if(e.src.indexOf("cdn.usefathom.com")<0){t="custom"}})();`, + ) + + it('applies neutralize-domain-check to bundle-only scripts (no proxy)', async () => { + mockUpstream(fathomLike) + const renderedScript = new Map() + + await runTransform( + `const instance = useScriptFathomAnalytics({ site: '123' }, { bundle: true })`, + { + renderedScript, + scripts: [ + { + bundle: { + resolve: () => 'https://cdn.usefathom.com/script.js', + sdkPatches: [{ type: 'neutralize-domain-check', domain: 'cdn.usefathom.com' }], + }, + import: { name: 'useScriptFathomAnalytics', from: '' }, + }, + ], + }, + ) + + const stored = [...renderedScript.values()][0] + expect(stored, 'bundle was not stored').toBeDefined() + const content = (stored.content as Buffer).toString('utf-8') + // Patch rewrites `< 0` to `< -1` on the fathom domain indexOf comparison, + // preserving the original whitespace (minified `<0` stays minified). + expect(content).toMatch(/indexOf\("cdn\.usefathom\.com"\)\s*<\s*-1/) + expect(content).not.toMatch(/indexOf\("cdn\.usefathom\.com"\)\s*<\s*0\b/) + }) + + it('leaves bundles untouched when no patches are configured', async () => { + mockUpstream(fathomLike) + const renderedScript = new Map() + + await runTransform( + `const instance = useScript('https://cdn.usefathom.com/script.js', { bundle: true })`, + { + renderedScript, + scripts: [ + { + bundle: { resolve: () => 'https://cdn.usefathom.com/script.js' }, + import: { name: 'useScript', from: '' }, + }, + ], + }, + ) + + const stored = [...renderedScript.values()][0] + expect(stored).toBeDefined() + const content = (stored.content as Buffer).toString('utf-8') + expect(content).toBe(fathomLike.toString('utf-8')) + }) +}) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 01cdc2d6..dc450f57 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -373,10 +373,9 @@ describe('proxy configs', () => { expect(config?.domains).toContain('basket.databuddy.cc') }) - it('returns proxy config for fathomAnalytics', async () => { + it('does not return proxy config for fathomAnalytics (removed: see #720, bot-detection flags proxied traffic)', async () => { const config = (await getProxyConfigs()).fathomAnalytics - expect(config).toBeDefined() - expect(config?.domains).toContain('cdn.usefathom.com') + expect(config).toBeUndefined() }) it('returns proxy config for intercom', async () => { @@ -407,7 +406,7 @@ describe('proxy configs', () => { }) describe('getProxyConfigs', () => { - it('returns all proxy configs (excluding removed: GTM, Segment, Crisp)', async () => { + it('returns all proxy configs (excluding removed: GTM, Segment, Crisp, Fathom)', async () => { const configs = await getProxyConfigs() expect(configs).toHaveProperty('googleAnalytics') expect(configs).not.toHaveProperty('googleTagManager') @@ -425,7 +424,7 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('rybbitAnalytics') expect(configs).toHaveProperty('umamiAnalytics') expect(configs).toHaveProperty('databuddyAnalytics') - expect(configs).toHaveProperty('fathomAnalytics') + expect(configs).not.toHaveProperty('fathomAnalytics') expect(configs).toHaveProperty('intercom') expect(configs).not.toHaveProperty('crisp') expect(configs).toHaveProperty('vercelAnalytics') diff --git a/test/unit/rewrite-ast.test.ts b/test/unit/rewrite-ast.test.ts index 561a93e1..13b36fa8 100644 --- a/test/unit/rewrite-ast.test.ts +++ b/test/unit/rewrite-ast.test.ts @@ -242,34 +242,27 @@ describe('rewriteScriptUrlsAST', () => { }) describe('fathom SDK self-hosted detection patching', () => { - async function getFathomConfig() { - const configs = await getProxyConfigs() - return configs.fathomAnalytics - } + // Fathom is bundle-only (no proxy capability) — sdkPatches come from + // BundleCapability.sdkPatches and are self-contained with their own domain. + const fathomPatches = [{ type: 'neutralize-domain-check' as const, domain: 'cdn.usefathom.com' }] - it('neutralizes .indexOf("cdn.usefathom.com") < 0', async () => { - const fathomConfig = await getFathomConfig() - const fathomRewrites = deriveRewrites(fathomConfig.domains, '/_scripts/p') + it('neutralizes .indexOf("cdn.usefathom.com") < 0', () => { const code = 'if(e.src.indexOf("cdn.usefathom.com")<0){t="custom"}' - const result = rewriteScriptUrlsAST(code, 'fathom.js', fathomRewrites, fathomConfig.sdkPatches) + const result = rewriteScriptUrlsAST(code, 'fathom.js', [], fathomPatches) expect(result).toContain('<-1') expect(result).not.toContain('<0') }) - it('neutralizes with whitespace around operator', async () => { - const fathomConfig = await getFathomConfig() - const fathomRewrites = deriveRewrites(fathomConfig.domains, '/_scripts/p') + it('neutralizes with whitespace around operator', () => { const code = 'if(e.src.indexOf("cdn.usefathom.com") < 0){t="custom"}' - const result = rewriteScriptUrlsAST(code, 'fathom.js', fathomRewrites, fathomConfig.sdkPatches) + const result = rewriteScriptUrlsAST(code, 'fathom.js', [], fathomPatches) expect(result).toContain('< -1') expect(result).not.toContain('< 0') }) - it('does not neutralize indexOf for non-rewrite domains', async () => { - const fathomConfig = await getFathomConfig() - const fathomRewrites = deriveRewrites(fathomConfig.domains, '/_scripts/p') + it('does not neutralize indexOf for non-matching domains', () => { const code = 'if(e.indexOf("other.com")<0){}' - const result = rewriteScriptUrlsAST(code, 'fathom.js', fathomRewrites, fathomConfig.sdkPatches) + const result = rewriteScriptUrlsAST(code, 'fathom.js', [], fathomPatches) expect(result).toContain('<0') })