Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions docs/content/docs/1.guides/2.first-party.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. Proxy privacy and anonymization do not apply to those direct requests.

::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

Expand DownExpand Up@@ -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 |
Expand Down
84 changes: 67 additions & 17 deletions packages/script/src/module.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -86,6 +86,42 @@ 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.
* 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',
'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; 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(normalizeNitroPreset(options.preset || ''))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export function applyAutoInject(
registry: NuxtConfigScriptRegistry,
runtimeConfig: Record<string, any>,
Expand DownExpand Up@@ -437,15 +473,26 @@ export default defineNuxtModule<ModuleOptions>({
__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<Record<RegistryScriptKey, ProxyConfig>> = {}

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. 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: (nuxt.options.nitro as any)?.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
Expand DownExpand Up@@ -648,6 +695,7 @@ export default defineNuxtModule<ModuleOptions>({
const partytownScripts = new Set<string>()

let anyNeedsProxy = false
const proxyConfiguredKeys: string[] = []
const registryKeys = Object.keys(config.registry || {})
for (const key of registryKeys) {
const script = scriptByKey.get(key)
Expand All@@ -663,8 +711,10 @@ export default defineNuxtModule<ModuleOptions>({

const resolved = resolveCapabilities(script, mergedOverrides)

if (resolved.proxy)
if (resolved.proxy) {
anyNeedsProxy = true
proxyConfiguredKeys.push(key)
}

if (resolved.partytown) {
partytownScripts.add(key)
Expand All@@ -687,8 +737,12 @@ export default defineNuxtModule<ModuleOptions>({
const proxyAlias = config.proxy?.alias
let domainAliases: Record<string, string> = {}

// 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)

Expand DownExpand Up@@ -796,17 +850,6 @@ export default defineNuxtModule<ModuleOptions>({
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
Expand All@@ -825,6 +868,13 @@ export default defineNuxtModule<ModuleOptions>({
}
}
}
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 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.`,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()

Expand Down
46 changes: 46 additions & 0 deletions test/unit/static-proxy-target.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
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', () => {
expect(isStaticProxyTarget({ generate: true })).toBe(true)
expect(isStaticProxyTarget({ generate: true, preset: 'cloudflare' })).toBe(true)
expect(isStaticProxyTarget({ nitroStatic: true })).toBe(true)
})

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')
})
})
Loading