Skip to content

Commit 0f27cbb

Browse files
authored
fix(fathom-analytics): drop proxy support, bundle-only with sdkPatches (#722)
1 parent f979ede commit 0f27cbb

9 files changed

Lines changed: 192 additions & 52 deletions

File tree

docs/content/scripts/fathom-analytics.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@ links:
1616
::script-docs
1717
::
1818

19+
## Proxying is not supported
20+
21+
Unlike most analytics integrations in Nuxt Scripts, Fathom **cannot** be proxied (`proxy: true`).
22+
23+
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.
24+
25+
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.
26+
27+
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.
28+
1929
## Defaults
2030

2131
- **Trigger**: Script will load when Nuxt is hydrated.

packages/script/src/plugins/rewrite-ast.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,9 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
358358

359359
// SDK patch: neutralize-domain-check
360360
// Matches `.indexOf("...domain...") < 0` and rewrites `< 0` to `< -1`
361-
if (sdkPatches?.some(p => p.type === 'neutralize-domain-check')
361+
const neutralizePatches = sdkPatches?.filter((p): p is { type: 'neutralize-domain-check', domain: string } =>
362+
p.type === 'neutralize-domain-check')
363+
if (neutralizePatches?.length
362364
&& node.type === 'BinaryExpression'
363365
&& (node as any).operator === '<') {
364366
const left = (node as any).left
@@ -372,7 +374,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
372374
if (prop === 'indexOf' && left.arguments?.length === 1) {
373375
const arg = left.arguments[0]
374376
if (arg?.type === 'Literal' && typeof arg.value === 'string'
375-
&& rewrites.some(r => arg.value.includes(r.from))) {
377+
&& neutralizePatches.some(p => arg.value.includes(p.domain))) {
376378
s.overwrite(right.start, right.end, '-1')
377379
}
378380
}

packages/script/src/plugins/transform.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,12 @@ async function downloadScript(opts: {
136136
let size = 0
137137
let fetched = false
138138

139-
// Use storage to cache the font data between builds
140-
// Include proxy in cache key to differentiate proxied vs non-proxied versions
141-
// Also include a hash of proxyRewrites content to handle different proxyPrefix values
142-
const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
143-
const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename.replace('.js', `${proxyRewritesHash}.js`)}` : `bundle:${filename}`
139+
// Cache patched bundles under a separate prefix so they don't collide with
140+
// raw bundles. Hash the rewrite/patch inputs so changes to either (different
141+
// proxyPrefix, new sdkPatches domain, etc.) invalidate the cache.
142+
const hasRewrites = !!(proxyRewrites?.length || sdkPatches?.length)
143+
const rewriteHash = hasRewrites ? `-${ohash({ proxyRewrites, sdkPatches })}` : ''
144+
const cacheKey = hasRewrites ? `bundle-patched:${filename.replace('.js', `${rewriteHash}.js`)}` : `bundle:${filename}`
144145
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
145146

146147
if (shouldUseCache) {
@@ -160,12 +161,14 @@ async function downloadScript(opts: {
160161
fetched = true
161162

162163
await storage.setItemRaw(`bundle:${filename}`, res)
163-
// Apply URL rewrites for proxy mode (AST-based at build time)
164-
if (proxyRewrites?.length && res) {
164+
// Apply AST rewrites at build time. Runs when either proxy rewrites are
165+
// present (proxy mode) or bundle-only sdkPatches are configured (e.g.
166+
// Fathom's neutralize-domain-check).
167+
if (hasRewrites && res) {
165168
const content = res.toString('utf-8')
166-
const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites, sdkPatches, { skipApiRewrites, neutralizeCanvas })
169+
const rewritten = rewriteScriptUrlsAST(content, filename, proxyRewrites ?? [], sdkPatches, { skipApiRewrites, neutralizeCanvas })
167170
res = Buffer.from(rewritten, 'utf-8')
168-
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
171+
logger.debug(`Rewrote ${proxyRewrites?.length ?? 0} URL patterns + ${sdkPatches?.length ?? 0} sdk patches in ${filename}`)
169172
}
170173

171174
await storage.setItemRaw(cacheKey, res)
@@ -466,8 +469,18 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
466469
from: domain,
467470
to: `${options.proxyPrefix}/${domain}`,
468471
}))
469-
const sdkPatches = proxyConfig?.sdkPatches
472+
// Bundle-only SDK patches (independent of proxy). Used when bundling
473+
// a script that needs neutralize-domain-check etc. but should keep
474+
// sending requests directly to its origin (e.g. Fathom).
475+
// When both are defined, proxyConfig.sdkPatches wins — proxy patches
476+
// are typically tuned for the rewritten URL set and should take precedence.
477+
const bundleConfig = typeof script?.bundle === 'object' ? script.bundle : undefined
478+
const sdkPatches = proxyConfig?.sdkPatches ?? bundleConfig?.sdkPatches
479+
// Skip API rewrites (sendBeacon/fetch/XHR/Image → __nuxtScripts.*) when:
480+
// 1. Partytown is active (uses resolveUrl instead), OR
481+
// 2. No proxy is active (no intercept plugin loaded — calls would crash)
470482
const skipApiRewrites = !!(registryKey && options.partytownScripts?.has(registryKey))
483+
|| !proxyConfig
471484
// Gate canvas fingerprinting neutralization on the script's hardware privacy flag
472485
const neutralizeCanvas = proxyConfig?.privacy !== undefined
473486
&& typeof proxyConfig.privacy === 'object'

packages/script/src/registry.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,12 @@ export const registryMeta: RegistryScriptMeta[] = [
116116
m('plausibleAnalytics', 'Plausible Analytics', 'analytics', 'useScriptPlausibleAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY),
117117
m('cloudflareWebAnalytics', 'Cloudflare Web Analytics', 'analytics', 'useScriptCloudflareWebAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY),
118118
m('posthog', 'PostHog', 'analytics', 'useScriptPostHog', { proxy: true }, PRIVACY_IP_ONLY),
119-
m('fathomAnalytics', 'Fathom Analytics', 'analytics', 'useScriptFathomAnalytics', { bundle: true, proxy: true, partytown: true }, PRIVACY_IP_ONLY),
119+
// proxy intentionally off: proxied beacons reach Fathom from the server's IP
120+
// (datacenter) and Fathom's bot detection ignores X-Forwarded-For, flagging
121+
// every visitor as a bot. Bundle is supported via neutralize-domain-check —
122+
// the script is served from the user's origin but beacons still go directly
123+
// to cdn.usefathom.com so Fathom sees real client IPs. See nuxt/scripts#720.
124+
m('fathomAnalytics', 'Fathom Analytics', 'analytics', 'useScriptFathomAnalytics', { bundle: true }, null),
120125
m('matomoAnalytics', 'Matomo Analytics', 'analytics', 'useScriptMatomoAnalytics', { proxy: true, partytown: true }, PRIVACY_IP_ONLY),
121126
m('rybbitAnalytics', 'Rybbit Analytics', 'analytics', 'useScriptRybbitAnalytics', { bundle: true, proxy: true }, PRIVACY_IP_ONLY),
122127
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<string>): Pro
337342
src: 'https://cdn.usefathom.com/script.js',
338343
category: 'analytics',
339344
envDefaults: { site: '' },
340-
bundle: true,
341-
proxy: {
342-
domains: ['cdn.usefathom.com', 'usefathom.com'],
343-
privacy: PRIVACY_IP_ONLY,
344-
sdkPatches: [{ type: 'neutralize-domain-check' }],
345+
// Bundle without proxy: serve the script from the user's origin (faster
346+
// load, ad-blocker resistant for domain-based blocking) but keep beacons
347+
// pointed at cdn.usefathom.com via the neutralize-domain-check patch so
348+
// Fathom sees real client IPs. Proxying is unsupported (see #720).
349+
bundle: {
350+
sdkPatches: [{ type: 'neutralize-domain-check', domain: 'cdn.usefathom.com' }],
345351
},
346-
partytown: { forwards: ['fathom', 'fathom.trackEvent', 'fathom.trackPageview'] },
347352
}),
348353
def('matomoAnalytics', {
349354
schema: MatomoAnalyticsOptions,

packages/script/src/runtime/types.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,13 @@ export interface ScriptDomain {
380380
export interface BundleCapability {
381381
/** Custom URL resolution. If omitted, the script's `src` is used. */
382382
resolve?: (options?: any) => string | false
383+
/**
384+
* AST-level SDK patches applied during bundling, independent of proxy.
385+
* Use for scripts that need self-hosted detection neutralization but should
386+
* still send beacons directly to the origin (e.g. Fathom, where proxying
387+
* triggers bot detection but bundling is otherwise safe).
388+
*/
389+
sdkPatches?: SdkPatch[]
383390
}
384391

385392
/**
@@ -408,11 +415,11 @@ export interface ProxyCapability {
408415
export type SdkPatch
409416
/**
410417
* Neutralize self-hosted detection checks like `.indexOf("cdn.example.com") < 0`.
411-
* When a script is proxied, its src no longer contains the original CDN domain,
412-
* causing these checks to incorrectly detect "self-hosted" mode.
418+
* When a script is bundled or proxied, its src no longer contains the original
419+
* CDN domain, causing these checks to incorrectly detect "self-hosted" mode.
413420
* This patch makes such comparisons always evaluate to false.
414421
*/
415-
= | { type: 'neutralize-domain-check' }
422+
= | { type: 'neutralize-domain-check', domain: string }
416423
/**
417424
* Replace `<expr>.split("<separator>")[0]` patterns used by SDKs that derive
418425
* their API host from `document.currentScript.src`. When bundled, the script src

test/e2e-dev/first-party.test.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -647,8 +647,8 @@ describe('first-party privacy stripping', () => {
647647
'redditPixel', // rdt('track', ...) triggers pixel fires
648648
'plausibleAnalytics', // plausible() triggers fetch POST
649649
'umamiAnalytics', // umami.track() triggers fetch POST
650-
'fathomAnalytics', // fathom.trackGoal() triggers beacon
651650
// cloudflareWebAnalytics — auto-engagement only, no CTA buttons
651+
// fathomAnalytics — bundle/proxy disabled (Fathom bot-detection flags self-hosted/proxied traffic, see #720)
652652
])
653653

654654
/**
@@ -673,7 +673,6 @@ describe('first-party privacy stripping', () => {
673673
'googleAnalytics', // scope-resolved AST rewrite for sendBeacon/fetch/XHR/Image
674674
'snapchatPixel', // scope-resolved AST rewrite for sendBeacon/XHR
675675
// googleTagManager — uses createElement('script') injection, not interceptable via XHR/fetch/sendBeacon
676-
'fathomAnalytics', // bundled + self-hosted detection neutralized, sendBeacon/Image interception
677676
'plausibleAnalytics', // bundled + auto-inject endpoint, sendBeacon interception (needs extension: 'local' + __plausible flag for headless)
678677
'tiktokPixel', // AST rewrite for analytics.tiktok.com, sendBeacon/fetch interception
679678
// databuddyAnalytics — SDK doesn't fire events with demo clientId in test window
@@ -910,13 +909,7 @@ describe('first-party privacy stripping', () => {
910909
}, { pre: preClickProxyCount, post: postClickProxyCount })
911910
}, 30000)
912911

913-
it('fathomAnalytics', async () => {
914-
const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('fathomAnalytics', '/fathom')
915-
await assertCaptures('fathomAnalytics', captures, rawCaptures, proxyRequests, externalRequests, {
916-
proxyPrefix: '/_scripts/p/fathom',
917-
domains: ['usefathom.com'],
918-
}, { pre: preClickProxyCount, post: postClickProxyCount })
919-
}, 30000)
912+
// fathomAnalytics — bundle/proxy disabled in registry (see #720), script loads directly from CDN
920913

921914
it('intercom', async () => {
922915
const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('intercom', '/intercom-test')
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Integration guard for bundle-only sdkPatches (nuxt/scripts#720 / Fathom).
2+
// Exercises the full NuxtScriptBundleTransformer → downloadScript →
3+
// rewriteScriptUrlsAST pipeline to prove the neutralize-domain-check patch is
4+
// actually applied to the stored bundle. A prior regression gated the rewrite
5+
// on proxyRewrites.length, so bundle-only patches were silently dropped while
6+
// direct unit tests of rewriteScriptUrlsAST still passed.
7+
import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform'
8+
import { hash } from 'ohash'
9+
import { hasProtocol } from 'ufo'
10+
import { describe, expect, it, vi } from 'vitest'
11+
import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform'
12+
13+
vi.mock('ohash', async (og) => {
14+
const mod = await og<typeof import('ohash')>()
15+
return { ...mod, hash: vi.fn(mod.hash) }
16+
})
17+
vi.mock('ufo', async (og) => {
18+
const mod = await og<typeof import('ufo')>()
19+
return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) }
20+
})
21+
22+
const mockBundleStorage: any = {
23+
getItem: vi.fn(),
24+
setItem: vi.fn(),
25+
getItemRaw: vi.fn(),
26+
setItemRaw: vi.fn(),
27+
hasItem: vi.fn().mockResolvedValue(false),
28+
}
29+
vi.mock('../../packages/script/src/assets', () => ({
30+
bundleStorage: vi.fn(() => mockBundleStorage),
31+
}))
32+
33+
const fetchMock = vi.fn()
34+
vi.stubGlobal('fetch', fetchMock)
35+
36+
vi.mock('@nuxt/kit', async (og) => {
37+
const mod = await og<typeof import('@nuxt/kit')>()
38+
const nuxt = {
39+
options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } },
40+
hooks: { hook: vi.fn() },
41+
}
42+
return { ...mod, useNuxt: () => nuxt, tryUseNuxt: () => nuxt }
43+
})
44+
45+
vi.mocked(hasProtocol).mockImplementation(() => true)
46+
vi.mocked(hash).mockImplementation(() => 'fathom-script')
47+
48+
function mockUpstream(bytes: Buffer) {
49+
fetchMock.mockResolvedValueOnce({
50+
ok: true,
51+
arrayBuffer: () => Promise.resolve(bytes),
52+
headers: { get: () => null },
53+
_data: bytes,
54+
} as any)
55+
}
56+
57+
async function runTransform(code: string, options: AssetBundlerTransformerOptions) {
58+
const plugin = NuxtScriptBundleTransformer(options).vite() as any
59+
await plugin.transform.handler.call({}, code, 'file.js')
60+
}
61+
62+
describe('bundle-only sdkPatches integration', () => {
63+
const fathomLike = Buffer.from(
64+
`(function(){var e=document.currentScript;if(e.src.indexOf("cdn.usefathom.com")<0){t="custom"}})();`,
65+
)
66+
67+
it('applies neutralize-domain-check to bundle-only scripts (no proxy)', async () => {
68+
mockUpstream(fathomLike)
69+
const renderedScript = new Map()
70+
71+
await runTransform(
72+
`const instance = useScriptFathomAnalytics({ site: '123' }, { bundle: true })`,
73+
{
74+
renderedScript,
75+
scripts: [
76+
{
77+
bundle: {
78+
resolve: () => 'https://cdn.usefathom.com/script.js',
79+
sdkPatches: [{ type: 'neutralize-domain-check', domain: 'cdn.usefathom.com' }],
80+
},
81+
import: { name: 'useScriptFathomAnalytics', from: '' },
82+
},
83+
],
84+
},
85+
)
86+
87+
const stored = [...renderedScript.values()][0]
88+
expect(stored, 'bundle was not stored').toBeDefined()
89+
const content = (stored.content as Buffer).toString('utf-8')
90+
// Patch rewrites `< 0` to `< -1` on the fathom domain indexOf comparison,
91+
// preserving the original whitespace (minified `<0` stays minified).
92+
expect(content).toMatch(/indexOf\("cdn\.usefathom\.com"\)\s*<\s*-1/)
93+
expect(content).not.toMatch(/indexOf\("cdn\.usefathom\.com"\)\s*<\s*0\b/)
94+
})
95+
96+
it('leaves bundles untouched when no patches are configured', async () => {
97+
mockUpstream(fathomLike)
98+
const renderedScript = new Map()
99+
100+
await runTransform(
101+
`const instance = useScript('https://cdn.usefathom.com/script.js', { bundle: true })`,
102+
{
103+
renderedScript,
104+
scripts: [
105+
{
106+
bundle: { resolve: () => 'https://cdn.usefathom.com/script.js' },
107+
import: { name: 'useScript', from: '' },
108+
},
109+
],
110+
},
111+
)
112+
113+
const stored = [...renderedScript.values()][0]
114+
expect(stored).toBeDefined()
115+
const content = (stored.content as Buffer).toString('utf-8')
116+
expect(content).toBe(fathomLike.toString('utf-8'))
117+
})
118+
})

test/unit/proxy-configs.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -373,10 +373,9 @@ describe('proxy configs', () => {
373373
expect(config?.domains).toContain('basket.databuddy.cc')
374374
})
375375

376-
it('returns proxy config for fathomAnalytics', async () => {
376+
it('does not return proxy config for fathomAnalytics (removed: see #720, bot-detection flags proxied traffic)', async () => {
377377
const config = (await getProxyConfigs()).fathomAnalytics
378-
expect(config).toBeDefined()
379-
expect(config?.domains).toContain('cdn.usefathom.com')
378+
expect(config).toBeUndefined()
380379
})
381380

382381
it('returns proxy config for intercom', async () => {
@@ -407,7 +406,7 @@ describe('proxy configs', () => {
407406
})
408407

409408
describe('getProxyConfigs', () => {
410-
it('returns all proxy configs (excluding removed: GTM, Segment, Crisp)', async () => {
409+
it('returns all proxy configs (excluding removed: GTM, Segment, Crisp, Fathom)', async () => {
411410
const configs = await getProxyConfigs()
412411
expect(configs).toHaveProperty('googleAnalytics')
413412
expect(configs).not.toHaveProperty('googleTagManager')
@@ -425,7 +424,7 @@ describe('proxy configs', () => {
425424
expect(configs).toHaveProperty('rybbitAnalytics')
426425
expect(configs).toHaveProperty('umamiAnalytics')
427426
expect(configs).toHaveProperty('databuddyAnalytics')
428-
expect(configs).toHaveProperty('fathomAnalytics')
427+
expect(configs).not.toHaveProperty('fathomAnalytics')
429428
expect(configs).toHaveProperty('intercom')
430429
expect(configs).not.toHaveProperty('crisp')
431430
expect(configs).toHaveProperty('vercelAnalytics')

0 commit comments

Comments
 (0)