From 8835f791efb5ea375d81f3bba133d48927764d90 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 21 Aug 2026 18:29:53 +1000 Subject: [PATCH 1/2] fix(proxy): disable proxying for static deployments --- docs/content/docs/1.guides/2.first-party.md | 20 ++---- packages/script/src/module.ts | 74 ++++++++++++++++----- test/unit/static-proxy-target.test.ts | 23 +++++++ 3 files changed, 84 insertions(+), 33 deletions(-) create mode 100644 test/unit/static-proxy-target.test.ts diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 138bfac4..2b8a99c4 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -236,23 +236,11 @@ PostHog receives the proxy endpoint through SDK config, so it can proxy collecti ### Static Hosting (SSG) -The reverse proxy requires a **server runtime**. A fully static deployment serves the output of [`nuxt generate`](https://nuxt.com/docs/getting-started/prerendering) without a Nitro process to handle `/_scripts/p/**`. Nuxt Scripts warns for known static presets, but it does not rewrite proxy URLs to their third-party origins. Disable proxying for affected scripts or use a host that supports external-origin rewrites. For example, [Vercel rewrites](https://vercel.com/docs/routing/rewrites) accept `/:path*` captures and external destinations: - -```json [vercel.json] -{ - "rewrites": [ - { "source": "/_scripts/p/www.google-analytics.com/:path*", "destination": "https://www.google-analytics.com/:path*" }, - { "source": "/_scripts/p/www.googletagmanager.com/:path*", "destination": "https://www.googletagmanager.com/:path*" }, - { "source": "/_scripts/p/connect.facebook.net/:path*", "destination": "https://connect.facebook.net/:path*" } - ] -} -``` +The reverse proxy requires a **server runtime**. A fully static deployment serves the output of [`nuxt generate`](https://nuxt.com/docs/getting-started/prerendering) without a Nitro process to handle `/_scripts/p/**`. -[Netlify proxy rewrites](https://docs.netlify.com/manage/routing/redirects/rewrites-proxies/) use a `200` rule such as `/_scripts/p/www.google-analytics.com/* https://www.google-analytics.com/:splat 200`. Cloudflare Pages is different: its [`_redirects` proxy rules](https://developers.cloudflare.com/pages/configuration/redirects/#proxying) support only relative destinations, not external domains. Use a [Pages Function](https://developers.cloudflare.com/pages/functions/) or Worker if you need this proxy on a static Cloudflare Pages deployment. Only configure domains your site uses; Nuxt DevTools → Scripts and Nitro logs show the registered set. +Nuxt Scripts detects static output (`nuxt generate`, `nuxt build --prerender`, or a static Nitro preset) and disables proxying automatically, with a build warning listing the affected scripts. Scripts still bundle and load from your domain, but their collection requests keep their original third-party URLs and go directly to their origins. -::callout{type="warning"} -Platform-level rewrites bypass the privacy anonymization layer. The proxy handler only runs in a Nitro server runtime. -:: +To keep requests proxied and anonymized, deploy the server output of `nuxt build` to a host that runs Nitro. ## Proxy Endpoint Security @@ -421,7 +409,7 @@ Routing a request through your domain does not settle the consent question. For | Problem | Fix | |---------|-----| | Analytics not tracking | Check DevTools → Network for `/_scripts/p/` requests. Check Nitro server logs for proxy errors | -| Proxy not working on static site | Static hosts do not run the Nitro proxy handler. Disable proxying, add platform rewrites, or switch to a server deployment. See [Static Hosting](#static-hosting-ssg) | +| Proxy not working on static site | Static output disables the proxy automatically and collection requests go direct. Deploy the `nuxt build` server output to enable proxying. See [Static Hosting](#static-hosting-ssg) | | Stale script | Remove `node_modules/.cache/nuxt/scripts` and rebuild | | Build download fails | Set `assets.fallbackOnSrcOnBundleFail: true`{lang="ts"} to fall back to direct loading | | Debugging | Open Nuxt DevTools → Scripts to see proxy routes and privacy status | diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index bbbbb2a5..0f87b6dd 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -86,6 +86,33 @@ export function isProxyDisabled( return false } +/** + * Nitro presets with no server runtime: `/_scripts/p/**` cannot be served, so + * proxy URLs written into bundled scripts would 404/405 in production. + */ +export const STATIC_PROXY_PRESETS = [ + 'static', + 'github-pages', + 'cloudflare-pages-static', + 'netlify-static', + 'azure-static', + 'firebase-static', + 'vercel-static', + 'zeabur-static', + 'zerops-static', +] + +/** + * Whether the build targets static output with no Nitro server runtime. + * `nuxi generate` and `nuxt build --prerender` set `_generate` and `nitro.static` + * through CLI overrides; explicit static presets arrive via `NITRO_PRESET` / `SERVER_PRESET`. + */ +export function isStaticProxyTarget(options: { generate?: boolean, nitroStatic?: boolean, preset?: string }): boolean { + return !!options.generate + || options.nitroStatic === true + || STATIC_PROXY_PRESETS.includes(options.preset || '') +} + export function applyAutoInject( registry: NuxtConfigScriptRegistry, runtimeConfig: Record, @@ -437,15 +464,25 @@ export default defineNuxtModule({ __NUXT_SCRIPTS_DEBUG__: debugConst, } - // Register proxy handler unconditionally. The handler rejects unknown domains - // at runtime, so it's safe to register even when no scripts use proxy. + // Register the proxy handler for server targets. The handler rejects unknown + // domains at runtime, so it's safe to register even when no scripts use proxy. const scriptsBase = config.prefix || '/_scripts' const proxyPrefix = `${scriptsBase}/p` const assetsPrefix = `${scriptsBase}/assets` const proxyConfigs: Partial> = {} const proxyHandlerPath = await resolvePath('./runtime/server/proxy-handler') - addServerHandler({ route: `${proxyPrefix}/**`, handler: proxyHandlerPath }) + // Static targets (nuxi generate, static presets) have no server runtime to + // serve the proxy: skip the route so the proxy is fully opt-in for them. + const staticProxyTarget = isStaticProxyTarget({ + // `_generate` arrives untyped through the nuxi generate CLI override. + generate: (nuxt.options as { _generate?: boolean })._generate, + nitroStatic: (nuxt.options.nitro as any)?.static, + preset: process.env.NITRO_PRESET || process.env.SERVER_PRESET, + }) + if (!staticProxyTarget) { + addServerHandler({ route: `${proxyPrefix}/**`, handler: proxyHandlerPath }) + } // In dev, sink Vercel Analytics insight POSTs to `/_vercel/insights/*` so // they don't 404. Vercel's edge serves this path in production; locally @@ -648,6 +685,7 @@ export default defineNuxtModule({ const partytownScripts = new Set() let anyNeedsProxy = false + const proxyConfiguredKeys: string[] = [] const registryKeys = Object.keys(config.registry || {}) for (const key of registryKeys) { const script = scriptByKey.get(key) @@ -663,8 +701,10 @@ export default defineNuxtModule({ const resolved = resolveCapabilities(script, mergedOverrides) - if (resolved.proxy) + if (resolved.proxy) { anyNeedsProxy = true + proxyConfiguredKeys.push(key) + } if (resolved.partytown) { partytownScripts.add(key) @@ -687,8 +727,12 @@ export default defineNuxtModule({ const proxyAlias = config.proxy?.alias let domainAliases: Record = {} - // Finalize proxy setup: build configs, register intercept plugin, wire devtools - if (anyNeedsProxy) { + // Finalize proxy setup: build configs, register intercept plugin, wire devtools. + // Static targets (nuxi generate, static presets) have no server runtime to + // serve `/_scripts/p/**`: skip every proxy integration (AST rewrites, intercept + // plugin, auto-injected endpoints) so collection requests keep their original + // third-party URLs and work on static hosting. + if (anyNeedsProxy && !staticProxyTarget) { const builtConfigs = buildProxyConfigsFromRegistry(registryScripts, scriptByKey) Object.assign(proxyConfigs, builtConfigs) @@ -796,17 +840,6 @@ export default defineNuxtModule({ logger.success(`Proxy mode enabled for ${registryKeys.length} script(s), ${totalDomains} domain(s) proxied (privacy: ${privacyLabel})`) } - // Warn for static presets - const proxyStaticPresets = ['static', 'github-pages', 'cloudflare-pages-static', 'netlify-static', 'azure-static', 'firebase-static'] - const proxyPreset = process.env.NITRO_PRESET || '' - if (proxyStaticPresets.includes(proxyPreset)) { - logger.warn( - `Proxy collection endpoints require a server runtime (detected: ${proxyPreset || 'static'}).\n` - + 'Scripts will be bundled, but collection requests will not be proxied.\n' - + 'Options: configure platform rewrites, switch to server-rendered mode, or disable with proxy: false.', - ) - } - // Expose devtools data if (nuxt.options.dev) { nuxt.options.runtimeConfig.public['nuxt-scripts-devtools'] = buildDevtoolsData(proxyPrefix, privacyLabel, devtoolsScripts, aliasToDomain) as any @@ -825,6 +858,13 @@ export default defineNuxtModule({ } } } + else if (anyNeedsProxy) { + logger.warn( + `[nuxt-scripts] Static output detected (nuxi generate or a static Nitro preset); the scripts proxy requires a server runtime.\n` + + `Proxying is disabled for: ${proxyConfiguredKeys.join(', ')}. Scripts still bundle, and their requests go directly to their third-party origins.\n` + + `Deploy the Nuxt server output (nuxt build) to enable proxying.`, + ) + } const moduleInstallPromises: Map Promise | undefined> = new Map() diff --git a/test/unit/static-proxy-target.test.ts b/test/unit/static-proxy-target.test.ts new file mode 100644 index 00000000..261ddfd6 --- /dev/null +++ b/test/unit/static-proxy-target.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { isStaticProxyTarget, STATIC_PROXY_PRESETS } from '../../packages/script/src/module' + +describe('isStaticProxyTarget', () => { + it('is false for a regular server build', () => { + expect(isStaticProxyTarget({ generate: false, nitroStatic: false, preset: 'cloudflare_pages' })).toBe(false) + expect(isStaticProxyTarget({})).toBe(false) + }) + + it('detects nuxi generate via _generate and nitro.static', () => { + expect(isStaticProxyTarget({ generate: true })).toBe(true) + expect(isStaticProxyTarget({ generate: true, preset: 'cloudflare' })).toBe(true) + expect(isStaticProxyTarget({ nitroStatic: true })).toBe(true) + }) + + it('detects static presets from NITRO_PRESET / SERVER_PRESET', () => { + for (const preset of STATIC_PROXY_PRESETS) { + expect(isStaticProxyTarget({ preset })).toBe(true) + } + expect(isStaticProxyTarget({ preset: '' })).toBe(false) + expect(isStaticProxyTarget({ preset: 'vercel' })).toBe(false) + }) +}) From 969f0e7db5d7342a502de4f2bd3076056f01bac2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 21 Aug 2026 18:39:57 +1000 Subject: [PATCH 2/2] fix(proxy): match nitro preset spellings and read nitro.preset config --- docs/content/docs/1.guides/2.first-party.md | 2 +- packages/script/src/module.ts | 24 +++++++++---- test/unit/static-proxy-target.test.ts | 37 +++++++++++++++++---- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 2b8a99c4..fc7cdbb2 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -238,7 +238,7 @@ PostHog receives the proxy endpoint through SDK config, so it can proxy collecti The reverse proxy requires a **server runtime**. A fully static deployment serves the output of [`nuxt generate`](https://nuxt.com/docs/getting-started/prerendering) without a Nitro process to handle `/_scripts/p/**`. -Nuxt Scripts detects static output (`nuxt generate`, `nuxt build --prerender`, or a static Nitro preset) and disables proxying automatically, with a build warning listing the affected scripts. Scripts still bundle and load from your domain, but their collection requests keep their original third-party URLs and go directly to their origins. +Nuxt Scripts detects static output (`nuxt generate`, `nuxt build --prerender`, or a static Nitro preset) and disables proxying automatically, with a build warning listing the affected scripts. Scripts still bundle and load from your domain, but their collection requests keep their original third-party URLs and go directly to their origins. Proxy privacy and anonymization do not apply to those direct requests. To keep requests proxied and anonymized, deploy the server output of `nuxt build` to a host that runs Nitro. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 0f87b6dd..87ba4c28 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -89,28 +89,37 @@ export function isProxyDisabled( /** * Nitro presets with no server runtime: `/_scripts/p/**` cannot be served, so * proxy URLs written into bundled scripts would 404/405 in production. + * Nitro accepts each name in hyphen, underscore, and camelCase form. */ export const STATIC_PROXY_PRESETS = [ 'static', 'github-pages', + 'gitlab-pages', 'cloudflare-pages-static', 'netlify-static', - 'azure-static', - 'firebase-static', 'vercel-static', 'zeabur-static', 'zerops-static', ] +/** + * Normalize a preset id the way Nitro resolves it: camelCase and underscore + * spellings (`githubPages`, `github_pages`) map to the hyphen form (`github-pages`). + */ +export function normalizeNitroPreset(preset: string): string { + return preset.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`).replace(/_/g, '-') +} + /** * Whether the build targets static output with no Nitro server runtime. * `nuxi generate` and `nuxt build --prerender` set `_generate` and `nitro.static` - * through CLI overrides; explicit static presets arrive via `NITRO_PRESET` / `SERVER_PRESET`. + * through CLI overrides; static presets arrive via `nitro.preset`, `NITRO_PRESET`, + * or `SERVER_PRESET` in any of Nitro's accepted spellings. */ export function isStaticProxyTarget(options: { generate?: boolean, nitroStatic?: boolean, preset?: string }): boolean { return !!options.generate || options.nitroStatic === true - || STATIC_PROXY_PRESETS.includes(options.preset || '') + || STATIC_PROXY_PRESETS.includes(normalizeNitroPreset(options.preset || '')) } export function applyAutoInject( @@ -475,10 +484,11 @@ export default defineNuxtModule({ // Static targets (nuxi generate, static presets) have no server runtime to // serve the proxy: skip the route so the proxy is fully opt-in for them. const staticProxyTarget = isStaticProxyTarget({ - // `_generate` arrives untyped through the nuxi generate CLI override. + // `_generate` arrives untyped through the nuxi generate CLI override. Nitro's + // preset option already merges CLI args, env, and nuxt.config by module setup. generate: (nuxt.options as { _generate?: boolean })._generate, nitroStatic: (nuxt.options.nitro as any)?.static, - preset: process.env.NITRO_PRESET || process.env.SERVER_PRESET, + preset: (nuxt.options.nitro as any)?.preset || process.env.NITRO_PRESET || process.env.SERVER_PRESET, }) if (!staticProxyTarget) { addServerHandler({ route: `${proxyPrefix}/**`, handler: proxyHandlerPath }) @@ -861,7 +871,7 @@ export default defineNuxtModule({ else if (anyNeedsProxy) { logger.warn( `[nuxt-scripts] Static output detected (nuxi generate or a static Nitro preset); the scripts proxy requires a server runtime.\n` - + `Proxying is disabled for: ${proxyConfiguredKeys.join(', ')}. Scripts still bundle, and their requests go directly to their third-party origins.\n` + + `Proxying and its privacy anonymization are disabled for: ${proxyConfiguredKeys.join(', ')}. Scripts still bundle, and their requests go directly to their third-party origins.\n` + `Deploy the Nuxt server output (nuxt build) to enable proxying.`, ) } diff --git a/test/unit/static-proxy-target.test.ts b/test/unit/static-proxy-target.test.ts index 261ddfd6..c792884a 100644 --- a/test/unit/static-proxy-target.test.ts +++ b/test/unit/static-proxy-target.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from 'vitest' -import { isStaticProxyTarget, STATIC_PROXY_PRESETS } from '../../packages/script/src/module' +import { isStaticProxyTarget, normalizeNitroPreset, STATIC_PROXY_PRESETS } from '../../packages/script/src/module' describe('isStaticProxyTarget', () => { it('is false for a regular server build', () => { expect(isStaticProxyTarget({ generate: false, nitroStatic: false, preset: 'cloudflare_pages' })).toBe(false) expect(isStaticProxyTarget({})).toBe(false) + expect(isStaticProxyTarget({ preset: 'vercel' })).toBe(false) + expect(isStaticProxyTarget({ preset: 'netlify' })).toBe(false) }) it('detects nuxi generate via _generate and nitro.static', () => { @@ -13,11 +15,32 @@ describe('isStaticProxyTarget', () => { expect(isStaticProxyTarget({ nitroStatic: true })).toBe(true) }) - it('detects static presets from NITRO_PRESET / SERVER_PRESET', () => { - for (const preset of STATIC_PROXY_PRESETS) { - expect(isStaticProxyTarget({ preset })).toBe(true) - } - expect(isStaticProxyTarget({ preset: '' })).toBe(false) - expect(isStaticProxyTarget({ preset: 'vercel' })).toBe(false) + it('detects every static preset in its hyphen, underscore, and camelCase form', () => { + expect(isStaticProxyTarget({ preset: 'static' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'github_pages' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'githubPages' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'gitlab_pages' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'cloudflare_pages_static' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'cloudflarePagesStatic' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'netlify_static' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'vercel_static' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'zeabur_static' })).toBe(true) + expect(isStaticProxyTarget({ preset: 'zerops_static' })).toBe(true) + }) + + it('keeps the preset list free of non-static or unknown presets', () => { + // azure-static and firebase-static are not Nitro presets; gitlab-pages is. + expect(STATIC_PROXY_PRESETS).not.toContain('azure-static') + expect(STATIC_PROXY_PRESETS).not.toContain('firebase-static') + expect(STATIC_PROXY_PRESETS).toContain('gitlab-pages') + }) +}) + +describe('normalizeNitroPreset', () => { + it('maps underscore and camelCase spellings onto the hyphen form', () => { + expect(normalizeNitroPreset('github_pages')).toBe('github-pages') + expect(normalizeNitroPreset('githubPages')).toBe('github-pages') + expect(normalizeNitroPreset('github-pages')).toBe('github-pages') + expect(normalizeNitroPreset('static')).toBe('static') }) })