From 567f95ec16c3487d1b22940edf69f607fdd0f328 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 21 Apr 2026 11:46:59 +1000 Subject: [PATCH 1/4] fix(fathom-analytics): drop proxy support, bundle-only with sdkPatches Fathom's bot detection uses the connecting source IP and ignores X-Forwarded-For from arbitrary servers. Proxying beacons made every visitor appear to originate from the server's datacenter IP, so they were flagged as bots. Remove proxy capability from the Fathom registry entry. Bundling is still supported: the script is served from the user's origin (faster load, ad-blocker resistant) and beacons go directly to cdn.usefathom.com via the existing neutralize-domain-check patch. To support this, BundleCapability gains its own optional sdkPatches field (independent of proxy), and the neutralize-domain-check patch now carries an explicit `domain` instead of inferring it from proxy rewrites. The transform plugin skips API rewrites when no proxy is active so bundled-only scripts don't crash trying to call __nuxtScripts.* with no intercept plugin loaded. Resolves #720 --- docs/content/scripts/fathom-analytics.md | 10 +++++++++ packages/script/src/plugins/rewrite-ast.ts | 6 ++++-- packages/script/src/plugins/transform.ts | 10 ++++++++- packages/script/src/registry.ts | 18 ++++++++++------ packages/script/src/runtime/types.ts | 13 ++++++++--- test/e2e-dev/first-party.test.ts | 11 ++-------- test/unit/proxy-configs.test.ts | 9 ++++---- test/unit/rewrite-ast.test.ts | 25 ++++++++-------------- 8 files changed, 60 insertions(+), 42 deletions(-) diff --git a/docs/content/scripts/fathom-analytics.md b/docs/content/scripts/fathom-analytics.md index b90e1b52a..2ef53593b 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 bd0c63ead..2e134eabb 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 466cc71c1..0f734e81f 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -466,8 +466,16 @@ 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). + 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 37bc14a56..12497470e 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, partytown: 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,11 +342,12 @@ 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'] }, }), diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 12fc5f8d7..4d01f6a8b 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 21d585699..0242a9b31 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/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 01cdc2d61..dc450f576 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 561a93e15..13b36fa8a 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') }) From 8fbf0db45cb94ac7b738955aadb04ce33d87572f Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 21 Apr 2026 11:59:39 +1000 Subject: [PATCH 2/4] fix(fathom-analytics): drop partytown capability Partytown's auto-configured resolveUrl routes all cross-origin requests through the proxy prefix. With partytown opted-in, Fathom beacons would get proxied via /_scripts/p/cdn.usefathom.com/* and trigger the bot detection failure this PR is meant to fix. Remove partytown from the Fathom registry entry and its matching forwards config. --- packages/script/src/registry.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 12497470e..1c9a2364f 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -121,7 +121,7 @@ export const registryMeta: RegistryScriptMeta[] = [ // 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, partytown: true }, null), + 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), @@ -349,7 +349,6 @@ export async function registry(resolve?: (path: string) => Promise): Pro bundle: { sdkPatches: [{ type: 'neutralize-domain-check', domain: 'cdn.usefathom.com' }], }, - partytown: { forwards: ['fathom', 'fathom.trackEvent', 'fathom.trackPageview'] }, }), def('matomoAnalytics', { schema: MatomoAnalyticsOptions, From 156e583daa6548550fb88d7e0ac20839025ab27c Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 16:43:42 +1000 Subject: [PATCH 3/4] fix(bundle): run AST rewrites when only sdkPatches are configured downloadScript gated the AST rewrite on proxyRewrites.length, so bundle-only scripts (Fathom) with sdkPatches but no proxy config skipped the rewrite entirely. The neutralize-domain-check patch never ran at build time, defeating the bundle path's purpose. Run the rewrite when either proxyRewrites or sdkPatches is present, and include sdkPatches in the cache key so patched/unpatched bundles don't collide. --- packages/script/src/plugins/transform.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 0f734e81f..59d38a135 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -138,9 +138,10 @@ async function downloadScript(opts: { // 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}` + // Also include a hash of proxyRewrites and sdkPatches content to handle different proxyPrefix values and bundle-only patches + const hasRewrites = !!(proxyRewrites?.length || sdkPatches?.length) + const rewriteHash = hasRewrites ? `-${ohash({ proxyRewrites, sdkPatches })}` : '' + const cacheKey = hasRewrites ? `bundle-proxy:${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) From c187072eec072ed2d11be05a6e4028ce211c9de3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Thu, 23 Apr 2026 17:04:18 +1000 Subject: [PATCH 4/4] test(bundle): guard bundle-only sdkPatches integration path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an integration test that runs the full transformer → downloadScript → rewriteScriptUrlsAST pipeline with a Fathom-like stub and asserts the neutralize-domain-check patch actually lands in the stored bundle. The earlier regression silently no-op'd because direct unit tests of rewriteScriptUrlsAST bypassed the build-time gate. Also rename the patched-bundle cache key from bundle-proxy: to bundle-patched: now that it covers bundle-only sdkPatches (no proxy), and document the proxy-over-bundle sdkPatches precedence. --- packages/script/src/plugins/transform.ts | 10 +- test/unit/bundle-sdk-patches.test.ts | 118 +++++++++++++++++++++++ 2 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 test/unit/bundle-sdk-patches.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 59d38a135..cda303475 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -136,12 +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 and sdkPatches content to handle different proxyPrefix values and bundle-only patches + // 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-proxy:${filename.replace('.js', `${rewriteHash}.js`)}` : `bundle:${filename}` + 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) { @@ -472,6 +472,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // 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: diff --git a/test/unit/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts new file mode 100644 index 000000000..dada8cda4 --- /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')) + }) +})