diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 517af6a0..f83d7977 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. 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 @@ -387,7 +375,7 @@ The module injects a per-request page token into the SSR payload, so the respons URL signing requires a server runtime to verify HMAC signatures. Two deployment modes cannot support signing: -**`nuxt generate` (SSG) with static hosting**: Prerendered pages contain proxy URLs, but no Nitro server exists at runtime to verify signatures or forward requests. Proxy endpoints will not work on static hosts such as GitHub Pages. If you need proxy endpoints alongside prerendered pages, deploy to a server target that supports runtime request handling; [Vercel supports both static and server-rendered Nuxt deployments](https://vercel.com/docs/frameworks/full-stack/nuxt). +**`nuxt generate` (SSG) with static hosting**: Static output has no proxy route and no proxy URLs; scripts send their requests directly to their third-party origins. No Nitro server exists at runtime to verify signatures or forward requests. If you need proxy endpoints alongside prerendered pages, deploy to a server target that supports runtime request handling; [Vercel supports both static and server-rendered Nuxt deployments](https://vercel.com/docs/frameworks/full-stack/nuxt). **`ssr: false` (SPA mode)**: No server-side rendering means no opportunity to sign URLs or embed page tokens. The signing secret lives in server-only runtime config and cannot be accessed from the client. Proxy endpoints still function if deployed with a server, but requests will be unsigned. @@ -550,7 +538,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 85dfad84..3c3a8064 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -288,6 +288,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 || '')) +} + export function applyAutoInject( registry: NuxtConfigScriptRegistry, runtimeConfig: Record, @@ -740,15 +776,26 @@ export default defineNuxtModule({ __NUXT_SCRIPTS_UNHEAD_SOURCELESS__: unheadSourceLessConst, } - // 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. 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 @@ -960,6 +1007,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) @@ -975,8 +1023,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) @@ -999,8 +1049,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, URL signing) 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) @@ -1108,17 +1162,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 and URL signing will be unavailable.\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 @@ -1137,6 +1180,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, its privacy anonymization, and proxy URL signing 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.`, + ) + } const moduleInstallPromises: Map Promise | undefined> = new Map() @@ -1227,10 +1277,7 @@ export default defineNuxtModule({ ) as any // Signing requires a server runtime to verify HMACs. Skip setup entirely - // for SPA mode or static presets where no Nitro server exists at runtime. - const staticPresets = ['static', 'github-pages', 'cloudflare-pages-static', 'netlify-static', 'azure-static', 'firebase-static'] - const nitroPreset = process.env.NITRO_PRESET || '' - const isStaticTarget = staticPresets.includes(nitroPreset) + // for SPA mode or static output where no Nitro server exists at runtime. const isSpa = nuxt.options.ssr === false // Proxy security explicitly disabled: skip secret resolution and the page @@ -1240,11 +1287,11 @@ export default defineNuxtModule({ logger.info('[security] Proxy security disabled via `security: false`. Proxy endpoints will pass requests through without signature verification.') } } - else if (anyHandlerRequiresSigning && (isSpa || isStaticTarget)) { + else if (anyHandlerRequiresSigning && (isSpa || staticProxyTarget)) { logger.warn( - `[security] URL signing requires a server runtime${isStaticTarget ? ` (detected preset: ${nitroPreset})` : ' (ssr: false)'}.\n` - + ' Proxy endpoints will work without signature verification.\n' - + ' To enable signing, deploy with a server-rendered target or configure platform-level rewrites.', + `[security] URL signing requires a server runtime${staticProxyTarget ? ' (static output)' : ' (ssr: false)'}.` + + '\n Proxy endpoints will work without signature verification.' + + '\n To enable signing, deploy with a server-rendered target or configure platform-level rewrites.', ) } // Resolve the HMAC signing secret only when at least one handler needs it diff --git a/test/unit/static-proxy-target.test.ts b/test/unit/static-proxy-target.test.ts new file mode 100644 index 00000000..c792884a --- /dev/null +++ b/test/unit/static-proxy-target.test.ts @@ -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') + }) +})