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+
constneutralizePatches=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
&&(nodeasany).operator==='<'){
364366
constleft=(nodeasany).left
@@ -372,7 +374,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
372374
if(prop==='indexOf'&&left.arguments?.length===1){
373375
constarg=left.arguments[0]
374376
if(arg?.type==='Literal'&&typeofarg.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
letsize=0
137137
letfetched=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-
constproxyRewritesHash=proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
143-
constcacheKey=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+
consthasRewrites=!!(proxyRewrites?.length||sdkPatches?.length)
143+
constrewriteHash=hasRewrites ? `-${ohash({ proxyRewrites, sdkPatches })}` : ''
144+
constcacheKey=hasRewrites ? `bundle-patched:${filename.replace('.js',`${rewriteHash}.js`)}` : `bundle:${filename}`
144145
constshouldUseCache=!forceDownload&&awaitstorage.hasItem(cacheKey)&&!(awaitisCacheExpired(storage,filename,cacheMaxAge))
145146

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

162163
awaitstorage.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
constcontent=res.toString('utf-8')
166-
constrewritten=rewriteScriptUrlsAST(content,filename,proxyRewrites,sdkPatches,{ skipApiRewrites, neutralizeCanvas })
169+
constrewritten=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
awaitstorage.setItemRaw(cacheKey,res)
@@ -466,8 +469,18 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
466469
from: domain,
467470
to: `${options.proxyPrefix}/${domain}`,
468471
}))
469-
constsdkPatches=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+
constbundleConfig=typeofscript?.bundle==='object' ? script.bundle : undefined
478+
constsdkPatches=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
constskipApiRewrites=!!(registryKey&&options.partytownScripts?.has(registryKey))
483+
||!proxyConfig
471484
// Gate canvas fingerprinting neutralization on the script's hardware privacy flag
472485
constneutralizeCanvas=proxyConfig?.privacy!==undefined
473486
&&typeofproxyConfig.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
exportinterfaceBundleCapability{
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
exporttypeSdkPatch
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+
importtype{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+
constmod=awaitog<typeofimport('ohash')>()
15+
return{ ...mod,hash: vi.fn(mod.hash)}
16+
})
17+
vi.mock('ufo',async(og)=>{
18+
constmod=awaitog<typeofimport('ufo')>()
19+
return{ ...mod,hasProtocol: vi.fn(mod.hasProtocol)}
20+
})
21+
22+
constmockBundleStorage: 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+
constfetchMock=vi.fn()
34+
vi.stubGlobal('fetch',fetchMock)
35+
36+
vi.mock('@nuxt/kit',async(og)=>{
37+
constmod=awaitog<typeofimport('@nuxt/kit')>()
38+
constnuxt={
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+
functionmockUpstream(bytes: Buffer){
49+
fetchMock.mockResolvedValueOnce({
50+
ok: true,
51+
arrayBuffer: ()=>Promise.resolve(bytes),
52+
headers: {get: ()=>null},
53+
_data: bytes,
54+
}asany)
55+
}
56+
57+
asyncfunctionrunTransform(code: string,options: AssetBundlerTransformerOptions){
58+
constplugin=NuxtScriptBundleTransformer(options).vite()asany
59+
awaitplugin.transform.handler.call({},code,'file.js')
60+
}
61+
62+
describe('bundle-only sdkPatches integration',()=>{
63+
constfathomLike=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+
constrenderedScript=newMap()
70+
71+
awaitrunTransform(
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+
conststored=[...renderedScript.values()][0]
88+
expect(stored,'bundle was not stored').toBeDefined()
89+
constcontent=(stored.contentasBuffer).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+
constrenderedScript=newMap()
99+
100+
awaitrunTransform(
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+
conststored=[...renderedScript.values()][0]
114+
expect(stored).toBeDefined()
115+
constcontent=(stored.contentasBuffer).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
constconfig=(awaitgetProxyConfigs()).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
constconfigs=awaitgetProxyConfigs()
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)