diff --git a/FIRST_PARTY.md b/FIRST_PARTY.md index 3e7dd384..a59c6e88 100644 --- a/FIRST_PARTY.md +++ b/FIRST_PARTY.md @@ -44,7 +44,7 @@ Some Path A scripts also define `autoInject` on their proxy config to set SDK en - **Rybbit**: `analyticsHost` → `/_scripts/p/app.rybbit.io/api` - **Databuddy**: `apiUrl` → `/_scripts/p/basket.databuddy.cc` -Auto-inject respects per-script `reverseProxyIntercept: false` opt-out (see "Per-script opt-out" below). +Auto-inject respects per-script `proxy: false` opt-out (see "Per-script opt-out" below). ### SDK-specific post-processing (`postProcess` on ProxyConfig) Some SDKs have quirks that require targeted regex patches after AST rewriting. These are defined as `postProcess` functions directly on each script's `ProxyConfig`: @@ -63,32 +63,32 @@ The only exception is `googleAdsense` which sets `proxy: 'googleAnalytics'` to s Scripts can opt out of first-party mode at three levels: -### Registry level (no `reverseProxyIntercept` capability) -Scripts without the `reverseProxyIntercept` capability in `registry.ts` are never proxied. Used for scripts that require fingerprinting for core functionality: +### Registry level (no `proxy` capability) +Scripts without the `proxy` capability in `registry.ts` are never proxied. Used for scripts that require fingerprinting for core functionality: - **Stripe**, **PayPal**: Fraud detection requires real client IP and browser fingerprints - **Google reCAPTCHA**: Bot detection requires real fingerprints - **Google Sign-In**: Auth integrity requires direct connection These scripts also have `scriptBundling: false` to prevent AST rewriting. -### Config level (`reverseProxyIntercept: false` in registry config) +### Config level (`proxy: false` in registry config) Users can opt out per-script in `nuxt.config.ts`: ``` -scripts.registry.plausibleAnalytics = { domain: 'mysite.com', reverseProxyIntercept: false } +scripts.registry.plausibleAnalytics = { domain: 'mysite.com', proxy: false } ``` -Or in tuple form with scriptOptions: +Using flat config syntax: ``` -scripts.registry.plausibleAnalytics = [{ domain: 'mysite.com' }, { reverseProxyIntercept: false }] +scripts.registry.plausibleAnalytics = { domain: 'mysite.com', proxy: false } ``` This skips domain registration, auto-inject, and AST rewriting for that script. Important for scripts with `autoInject` (Plausible, PostHog, Umami, Rybbit, Databuddy) since `autoInject` runs at module setup before transforms. -### Composable level (`scriptOptions: { reverseProxyIntercept: false }`) +### Composable level (`scriptOptions: { proxy: false }`) ```ts useScriptPlausibleAnalytics({ - scriptOptions: { reverseProxyIntercept: false } + scriptOptions: { proxy: false } }) ``` @@ -102,49 +102,51 @@ This only affects AST rewriting (the transform plugin skips proxy rewrites for t For npm-mode scripts (no download), define `autoInject` to configure the SDK's endpoint field. -For scripts that need fingerprinting (payments, CAPTCHA, auth), omit the `reverseProxyIntercept` capability and set `scriptBundling: false` in the registry entry. +For scripts that need fingerprinting (payments, CAPTCHA, auth), omit the `proxy` capability and set `scriptBundling: false` in the registry entry. ## Privacy presets -Four presets in `proxy-configs.ts` cover all scripts: +Four presets in `proxy-configs.ts` cover all proxy-enabled scripts: | Preset | Flags | Used by | |---|---|---| -| `PRIVACY_NONE` | all false | GTM, PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, Vercel, Segment, Carbon Ads, Lemon Squeezy, Matomo | +| `PRIVACY_NONE` | all false | (not currently assigned to any script) | | `PRIVACY_FULL` | all true | Meta, TikTok, X, Snap, Reddit | | `PRIVACY_HEATMAP` | ip, language, hardware | GA, Clarity, Hotjar | -| `PRIVACY_IP_ONLY` | ip only | Intercom, Crisp, Gravatar, YouTube, Vimeo | +| `PRIVACY_IP_ONLY` | ip only | PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo | + +Note: GTM, Segment, Crisp, Mixpanel, and Bing UET are bundle-only (no proxy capability), so no privacy transforms are applied. ## Script Support | Config Key | Registry Scripts | Privacy | Mechanism | |---|---|---|---| | `googleAnalytics` | googleAnalytics, **googleAdsense** | `PRIVACY_HEATMAP` | Path A | -| `googleTagManager` | googleTagManager | `PRIVACY_NONE` | Path A | | `metaPixel` | metaPixel | `PRIVACY_FULL` | Path A | | `tiktokPixel` | tiktokPixel | `PRIVACY_FULL` | Path A | | `xPixel` | xPixel | `PRIVACY_FULL` | Path A | | `snapchatPixel` | snapchatPixel | `PRIVACY_FULL` | Path A | | `redditPixel` | redditPixel | `PRIVACY_FULL` | Path A | -| `carbonAds` | carbonAds | `PRIVACY_NONE` | Path A | -| `lemonSqueezy` | lemonSqueezy | `PRIVACY_NONE` | Path A | -| `matomoAnalytics` | matomoAnalytics | `PRIVACY_NONE` | Path A | -| `youtubePlayer` | youtubePlayer | `PRIVACY_IP_ONLY` | Path A | -| `vimeoPlayer` | vimeoPlayer | `PRIVACY_IP_ONLY` | Path A | -| `segment` | segment | `PRIVACY_NONE` | Path A | | `clarity` | clarity | `PRIVACY_HEATMAP` | Path A | | `hotjar` | hotjar | `PRIVACY_HEATMAP` | Path A | -| `posthog` | posthog | `PRIVACY_NONE` | **Path B** (npm-only) + autoInject | -| `plausibleAnalytics` | plausibleAnalytics | `PRIVACY_NONE` | Path A + autoInject | -| `umamiAnalytics` | umamiAnalytics | `PRIVACY_NONE` | Path A + autoInject | -| `rybbitAnalytics` | rybbitAnalytics | `PRIVACY_NONE` | Path A + autoInject + postProcess | -| `databuddyAnalytics` | databuddyAnalytics | `PRIVACY_NONE` | Path A + autoInject | -| `fathomAnalytics` | fathomAnalytics | `PRIVACY_NONE` | Path A + postProcess | -| `cloudflareWebAnalytics` | cloudflareWebAnalytics | `PRIVACY_NONE` | Path A | -| `vercelAnalytics` | vercelAnalytics | `PRIVACY_NONE` | Path A | +| `posthog` | posthog | `PRIVACY_IP_ONLY` | **Path B** (npm-only) + autoInject | +| `plausibleAnalytics` | plausibleAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject | +| `umamiAnalytics` | umamiAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject | +| `rybbitAnalytics` | rybbitAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject + postProcess | +| `databuddyAnalytics` | databuddyAnalytics | `PRIVACY_IP_ONLY` | Path A + autoInject | +| `fathomAnalytics` | fathomAnalytics | `PRIVACY_IP_ONLY` | Path A + postProcess | +| `cloudflareWebAnalytics` | cloudflareWebAnalytics | `PRIVACY_IP_ONLY` | Path A | +| `vercelAnalytics` | vercelAnalytics | `PRIVACY_IP_ONLY` | Path A | +| `matomoAnalytics` | matomoAnalytics | `PRIVACY_IP_ONLY` | Path A | +| `carbonAds` | carbonAds | `PRIVACY_IP_ONLY` | Path A | +| `lemonSqueezy` | lemonSqueezy | `PRIVACY_IP_ONLY` | Path A | +| `youtubePlayer` | youtubePlayer | `PRIVACY_IP_ONLY` | Path A | +| `vimeoPlayer` | vimeoPlayer | `PRIVACY_IP_ONLY` | Path A | | `intercom` | intercom | `PRIVACY_IP_ONLY` | Path A | -| `crisp` | crisp | `PRIVACY_IP_ONLY` | Path A | | `gravatar` | gravatar | `PRIVACY_IP_ONLY` | Path A | +| `googleTagManager` | googleTagManager | n/a | Bundle only | +| `segment` | segment | n/a | Bundle only | +| `crisp` | crisp | n/a | Bundle only | ### Excluded from first-party mode (`proxy: false`) @@ -168,7 +170,7 @@ The server handler extracts the target domain directly from the proxy path (`/_s ### Two-phase setup, configs built once `module.ts` calls two functions: 1. `setupFirstParty(config, resolvePath)` — registers the proxy handler unconditionally (handler rejects unknown domains at runtime). Returns `FirstPartyConfig`. -2. In `modules:done`: resolves capabilities for each configured script via `resolveCapabilities()`, then calls `finalizeFirstParty({...})` which builds proxy configs from the registry, collects domain privacy mappings, applies auto-injects, registers the intercept plugin, and populates runtimeConfig. Respects per-entry `reverseProxyIntercept: false` opt-out. +2. In `modules:done`: resolves capabilities for each configured script via `resolveCapabilities()`, then calls `finalizeFirstParty({...})` which builds proxy configs from the registry, collects domain privacy mappings, applies auto-injects, registers the intercept plugin, and populates runtimeConfig. Respects per-entry `proxy: false` opt-out. The transform plugin receives the pre-built `proxyConfigs` map via options — direct lookup per-script, no rebuilding. @@ -201,8 +203,8 @@ GA defaults (`PRIVACY_HEATMAP`): anonymizes ip/language/hardware, passes through - `docs/content/docs/1.guides/2.first-party.md` — main docs page ### Config options -- `firstParty: true | false | { proxyPrefix?, privacy? }` — module-level option -- `proxyPrefix` — proxy endpoint path prefix (default: `/_scripts/p`) +- `proxy: false | { prefix?, privacy? }` — module-level option (auto-inferred when any script has proxy capability) +- `proxy.prefix` — proxy endpoint path prefix (default: `/_scripts/p`) - `assets.prefix` — bundled script asset path (default: `/_scripts/assets`) -- Per-script `firstParty: false` — in registry config input or scriptOptions to opt out individual scripts -- Per-script `proxy: false` — in registry.ts for scripts that must never be proxied (fingerprinting requirements) +- Per-script `proxy: false` — in flat config or scriptOptions to opt out individual scripts +- Registry-level `proxy: false` — in registry.ts capabilities for scripts that must never be proxied (fingerprinting requirements) diff --git a/build.config.ts b/build.config.ts index 6fb80700..f8801682 100644 --- a/build.config.ts +++ b/build.config.ts @@ -7,9 +7,6 @@ export default defineBuildConfig({ './src/stats', './src/types-source', ], - alias: { - '#nuxt-scripts-validator': 'valibot', - }, externals: [ 'nuxt', 'nuxt/schema', diff --git a/client/app.vue b/client/app.vue index 22c1aed6..5dce2d99 100644 --- a/client/app.vue +++ b/client/app.vue @@ -207,7 +207,7 @@ function privacyLevelBg(level: string) { // Capability visualization helpers const capabilityDefs = [ { key: 'bundle', label: 'Bundle', icon: 'carbon:archive', desc: 'Downloaded at build time, served from your domain' }, - { key: 'reverseProxyIntercept', label: 'Proxy', icon: 'carbon:security', desc: 'Collection requests routed through your server' }, + { key: 'proxy', label: 'Proxy', icon: 'carbon:security', desc: 'Collection requests routed through your server' }, { key: 'partytown', label: 'Partytown', icon: 'carbon:container-software', desc: 'Can run in a web worker via Partytown' }, ] as const @@ -237,7 +237,7 @@ function capStateLabel(state: CapState): string { function capSummary(script: any): string { const parts: string[] = [] if (script.capabilities?.bundle) parts.push('bundle') - if (script.capabilities?.reverseProxyIntercept) parts.push('proxy') + if (script.capabilities?.proxy) parts.push('proxy') if (script.capabilities?.partytown) parts.push('partytown') return parts.length ? parts.join(' · ') : 'none' } diff --git a/docs/content/docs/1.guides/1.registry-scripts.md b/docs/content/docs/1.guides/1.registry-scripts.md index f1241fb6..49291dc5 100644 --- a/docs/content/docs/1.guides/1.registry-scripts.md +++ b/docs/content/docs/1.guides/1.registry-scripts.md @@ -257,8 +257,8 @@ export default defineNuxtConfig({ Then create your custom script composable: ```ts [composables/useScriptMyAnalytics.ts] -import { object, string } from '#nuxt-scripts-validator' import { useRegistryScript } from '#nuxt-scripts/utils' +import { object, string } from 'valibot' export interface MyAnalyticsApi { track: (event: string, data?: Record) => void diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 834a9daf..7926e522 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -9,137 +9,208 @@ Every third-party script your site loads connects your users directly to externa Ad blockers rightfully block these requests, which breaks analytics for sites that depend on them. -## How First-Party Mode Works +## What You Get -First-party mode puts you in control of your users' requests: +First-party mode provides two layers of protection: -1. **Build time**: Scripts are downloaded and served from your domain. URLs pointing to third-party domains are rewritten to route through your server (e.g., `www.google-analytics.com/g/collect` → `/_scripts/p/www.google-analytics.com/g/collect`) -2. **Runtime**: Nitro proxies requests back to the original endpoints, anonymizing user data before forwarding +**Layer 1: Routing.** Scripts are bundled and served from your domain. Collection requests proxy through your server. Users never connect to third-party servers, so no third-party cookies are set and ad blockers don't interfere. -Your users never connect directly to third-party servers. Third parties see your server's IP, not your users'. Requests are same-origin so no third-party cookies are set, and ad blockers don't interfere. +**Layer 2: Anonymization.** Before forwarding requests to the original endpoints, the proxy strips identifying data. IP addresses are anonymized to subnet level. Sensitive headers (cookies, auth tokens) are always removed. Additional anonymization (user agent, screen dimensions, hardware fingerprints) is applied per script based on what would break functionality. + +Even with minimal anonymization, routing alone prevents direct browser connections, eliminates third-party cookies, and ensures requests appear same-origin. ## Usage -First-party mode is **auto-enabled** for all scripts that support it. Add scripts to your registry: +First-party mode is **auto-enabled** for all scripts that support it. Adding a script to the registry enables infrastructure (proxy routes, bundling, types, composables) without auto-loading it: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { registry: { + // Infrastructure only — use composables to load on specific pages googleAnalytics: { id: 'G-XXXXXX' }, metaPixel: { id: '123456' }, + + // Infrastructure + global auto-load + plausibleAnalytics: { domain: 'mysite.com', trigger: 'onNuxtReady' }, } } }) ``` -Each script in the registry declares its own capabilities. Scripts that support `reverseProxyIntercept` will automatically route collection requests through your domain. Scripts that support `bundle` will be downloaded at build time and served locally. +Scripts without `trigger` are infrastructure only: the module prepares any supported infrastructure for that script (proxy routes, bundling, composables), but the script only loads when you call the composable in a component. Add `trigger` to auto-load globally. -### Disabling Proxy Globally +## Privacy Tiers + +Every proxied script defaults to a privacy tier based on what level of anonymization is safe for that script's functionality. Three tiers cover all scripts: + +| Tier | What's anonymized | Scripts | +|------|-------------------|---------| +| **Full** | IP, user agent, language, screen, timezone, hardware fingerprints | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel | +| **Heatmap-safe** | IP, language, hardware fingerprints (preserves screen and user agent for session replay) | Google Analytics, Microsoft Clarity, Hotjar | +| **IP only** | IP addresses anonymized to subnet level | Plausible, PostHog, Umami, Fathom, CF Web Analytics, Vercel Analytics, Rybbit, Databuddy, Matomo, Intercom, YouTube, Vimeo, Gravatar, Carbon Ads, Lemon Squeezy, Google AdSense | + +Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of tier. + +### Six Privacy Flags + +Each tier maps to a combination of six flags: + +| Flag | What it does | +|------|-------------| +| `ip` | Anonymizes IP addresses to subnet level in headers and payload params | +| `userAgent` | Normalizes User-Agent to browser family + major version (e.g. `Mozilla/5.0 (compatible; Chrome/131.0)`{lang="ts"}) | +| `language` | Normalizes Accept-Language to primary language tag | +| `screen` | Generalizes screen resolution, viewport, hardware concurrency, and device memory to common buckets | +| `timezone` | Generalizes timezone offset and IANA timezone names | +| `hardware` | Anonymizes canvas/webgl/audio fingerprints, plugin/font lists, browser versions, and device info | + +### Tier Flag Matrix + +| Flag | IP Only | Heatmap-safe | Full | +|------|:-------:|:------------:|:----:| +| `ip` | :icon{name="i-heroicons-check"} | :icon{name="i-heroicons-check"} | :icon{name="i-heroicons-check"} | +| `userAgent` | | | :icon{name="i-heroicons-check"} | +| `language` | | :icon{name="i-heroicons-check"} | :icon{name="i-heroicons-check"} | +| `screen` | | | :icon{name="i-heroicons-check"} | +| `timezone` | | | :icon{name="i-heroicons-check"} | +| `hardware` | | :icon{name="i-heroicons-check"} | :icon{name="i-heroicons-check"} | -To disable all proxying: +**IP Only** anonymizes the IP to a /24 subnet (city-level geo accuracy). **Heatmap-safe** strips language and hardware fingerprints while preserving user agent and screen dimensions needed for session replay tools. **Full** strips everything, used for ad pixels where no analytics reporting depends on raw client data. + +### Global Override + +Override all per-script defaults at once: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - proxy: false + proxy: { + privacy: true, // Full anonymization for ALL scripts + } } }) ``` -### Per-Script Opt-Out - -Disable proxying for a specific script using `reverseProxyIntercept: false`: +Or selectively override specific flags: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - registry: { - plausibleAnalytics: { domain: 'mysite.com', reverseProxyIntercept: false }, - googleAnalytics: { id: 'G-XXXXXX' }, // still proxied + proxy: { + privacy: { ip: true }, // Anonymize IP for all scripts, rest uses per-script defaults } } }) ``` -This also works in tuple form: +### Disabling Anonymization + +If you need raw data forwarded (e.g. self-hosted PostHog where you control the data), disable anonymization per-script or globally: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - registry: { - plausibleAnalytics: [{ domain: 'mysite.com' }, { reverseProxyIntercept: false }], + proxy: { + privacy: false, // No anonymization for any script (routing still active) } } }) ``` -### Scripts That Bypass First-Party Mode +## Opting Out -Some scripts require direct browser fingerprinting to function correctly. These are automatically excluded from first-party mode and always load directly: - -| Script | Reason | -|--------|--------| -| **Stripe** | Fraud detection requires real client fingerprints | -| **PayPal** | Fraud detection requires real client fingerprints | -| **Google reCAPTCHA** | Bot detection requires real browser fingerprints | -| **Google Sign-In** | Auth integrity requires direct connection | - -These scripts still work normally, they connect directly to the third-party server instead of routing through your domain. - -### Privacy Controls - -Each script in the registry declares its own privacy defaults based on what data it needs. Privacy is controlled by six flags: - -| Flag | What it does | -|------|-------------| -| `ip` | Anonymizes IP addresses to subnet level in headers and payload params | -| `userAgent` | Normalizes User-Agent to browser family + major version (e.g. `Mozilla/5.0 (compatible; Chrome/131.0)`{lang="ts"}) | -| `language` | Normalizes Accept-Language to primary language tag | -| `screen` | Generalizes screen resolution, viewport, hardware concurrency, and device memory to common buckets | -| `timezone` | Generalizes timezone offset and IANA timezone names | -| `hardware` | Anonymizes canvas/webgl/audio fingerprints, plugin/font lists, browser versions, and device info | - -Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of privacy settings. - -### Per-Script Defaults +### Disabling Proxy Globally -Four privacy presets cover all scripts: +To disable all proxying (scripts load directly from third-party servers): -| Preset | Flags | Scripts | -|--------|-------|---------| -| **Full** | all flags | Meta Pixel, TikTok Pixel, X Pixel, Snapchat Pixel, Reddit Pixel | -| **Heatmap-safe** | ip, language, hardware | Google Analytics, Microsoft Clarity, Hotjar | -| **IP only** | ip | Intercom, Crisp, Gravatar, YouTube, Vimeo | -| **None** | - | GTM, PostHog, Plausible, Umami, Rybbit, Databuddy, Fathom, CF Web Analytics, [Vercel](https://vercel.com), Segment, Carbon Ads, Lemon Squeezy, Matomo | +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + proxy: false + } +}) +``` -#### Global Override +### Per-Script Opt-Out -Override all per-script defaults at once: +Disable proxying for a specific script using `proxy: false`: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - proxy: { - privacy: true, // Full anonymize for ALL scripts + registry: { + plausibleAnalytics: { domain: 'mysite.com', proxy: false }, + googleAnalytics: { id: 'G-XXXXXX' }, // still proxied } } }) ``` -Or selectively override specific flags: +Capabilities are set alongside script input in flat config: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { - proxy: { - privacy: { ip: true }, // Anonymize IP for all scripts, rest uses per-script defaults + registry: { + plausibleAnalytics: { domain: 'mysite.com', proxy: false }, } } }) ``` -### Partytown (Web Worker) +## Supported Scripts + +### Full First-Party (Bundled + Proxied) + +These scripts are downloaded at build time, served from your domain, and have their collection requests proxied through your server: + +| Category | Scripts | +|----------|---------| +| **Analytics** | [Google Analytics](/scripts/google-analytics), [Plausible](/scripts/plausible-analytics), [Cloudflare Web Analytics](/scripts/cloudflare-web-analytics), [Umami](/scripts/umami-analytics), [Fathom](/scripts/fathom-analytics), [Rybbit](/scripts/rybbit-analytics), [Databuddy](/scripts/databuddy-analytics), [Vercel Analytics](/scripts/vercel-analytics), [Microsoft Clarity](/scripts/clarity), [Hotjar](/scripts/hotjar) | +| **Ad Pixels** | [Meta Pixel](/scripts/meta-pixel), [TikTok Pixel](/scripts/tiktok-pixel), [X Pixel](/scripts/x-pixel), [Snapchat Pixel](/scripts/snapchat-pixel), [Reddit Pixel](/scripts/reddit-pixel), [Google AdSense](/scripts/google-adsense) | +| **Video** | [YouTube Player](/scripts/youtube-player), [Vimeo Player](/scripts/vimeo-player) | +| **Utility** | [Intercom](/scripts/intercom), [Gravatar](/scripts/gravatar) | + +### Proxy Only (Not Bundled) + +These scripts can't be bundled at build time, but their collection requests are still proxied through your server: + +| Script | How it works | +|--------|-------------| +| [PostHog](/scripts/posthog) | SDK installed via [npm](https://npmjs.com). Proxy endpoint auto-injected via `apiHost` config. | +| [Matomo](/scripts/matomo-analytics) | Self-hosted; bundling breaks the script. Proxy routes registered for collection requests. | +| [Carbon Ads](/scripts/carbon-ads) | Ad serving script. Proxy routes registered for collection requests. | +| [Lemon Squeezy](/scripts/lemon-squeezy) | Payment widget. Proxy routes registered for collection requests. | + +### Bundle Only (No Proxy) + +These scripts are served from your domain but their runtime requests still go directly to third-party servers: + +| Script | Why proxy isn't supported | +|--------|--------------------------| +| [Google Tag Manager](/scripts/google-tag-manager) | GTM's core function is loading other scripts at runtime. Those runtime scripts bypass build-time rewriting. | +| [Segment](/scripts/segment) | SDK constructs API URLs dynamically, bypassing request interception. | +| [Crisp](/scripts/crisp) | SDK loads secondary scripts and CSS at runtime from `client.crisp.chat`. | +| [Mixpanel](/scripts/mixpanel-analytics) | No proxy integration yet. | +| [Bing UET](/scripts/bing-uet) | No proxy integration yet. | + +Bundle-only scripts still benefit from being served as first-party assets (faster loading, no CORS, reduced external connections at page load). + +### Excluded (Direct Loading Only) + +These scripts require direct browser connections for security: + +| Script | Reason | +|--------|--------| +| **Stripe** | Fraud detection requires real client fingerprints | +| **PayPal** | Fraud detection requires real client fingerprints | +| **Google reCAPTCHA** | Bot detection requires real browser fingerprints | +| **Google Sign-In** | Auth integrity requires direct connection | + +These scripts still work normally, they connect directly to the third-party server instead of routing through your domain. + +## Partytown (Web Worker) Load individual scripts off the main thread by setting `partytown: true` per-script: @@ -148,8 +219,8 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/partytown', '@nuxt/scripts'], scripts: { registry: { - plausibleAnalytics: [{ domain: 'example.com' }, { partytown: true }], - fathomAnalytics: [{ site: 'XXXXX' }, { partytown: true }], + plausibleAnalytics: { domain: 'example.com', partytown: true }, + fathomAnalytics: { site: 'XXXXX', partytown: true }, } } }) @@ -161,29 +232,13 @@ Forward arrays are auto-configured for supported scripts. Requires `@nuxtjs/part GA4 has [known issues](https://github.com/BuilderIO/partytown/issues/583) with Partytown. GTM is not compatible (requires DOM access). Consider Plausible, Fathom, or Umami instead. :: -## Supported Scripts - -First-party mode supports all registry scripts except those requiring fingerprinting: - -| Category | Scripts | -|----------|---------| -| **Analytics** | [Google Analytics](/scripts/google-analytics), [Google Tag Manager](/scripts/google-tag-manager), [PostHog](/scripts/posthog), [Plausible](/scripts/plausible-analytics), [Cloudflare Web Analytics](/scripts/cloudflare-web-analytics), [Umami](/scripts/umami-analytics), [Fathom](/scripts/fathom-analytics), [Rybbit](/scripts/rybbit-analytics), [Databuddy](/scripts/databuddy-analytics), [Vercel Analytics](/scripts/vercel-analytics), [Matomo](/scripts/matomo-analytics), [Segment](/scripts/segment), [Microsoft Clarity](/scripts/clarity), [Hotjar](/scripts/hotjar) | -| **Ad Pixels** | [Meta Pixel](/scripts/meta-pixel), [TikTok Pixel](/scripts/tiktok-pixel), [X Pixel](/scripts/x-pixel), [Snapchat Pixel](/scripts/snapchat-pixel), [Reddit Pixel](/scripts/reddit-pixel), [Google AdSense](/scripts/google-adsense), [Carbon Ads](/scripts/carbon-ads) | -| **Payments** | [Lemon Squeezy](/scripts/lemon-squeezy) | -| **Video** | [YouTube Player](/scripts/youtube-player), [Vimeo Player](/scripts/vimeo-player) | -| **Utility** | [Intercom](/scripts/intercom), [Crisp](/scripts/crisp), [Gravatar](/scripts/gravatar) | - -::callout{type="warning"} -**Stripe**, **PayPal**, **Google reCAPTCHA**, and **Google Sign-In** are excluded from first-party mode because they require browser fingerprinting for fraud detection, bot detection, or auth integrity. -:: - ## Static Hosting First-party mode requires a **server runtime** for the proxy endpoints. For static deployments (`nuxt generate`), scripts are still bundled with rewritten URLs but you'll need to configure platform rewrites manually. The pattern is `/_scripts/p//:path*` → `https:///:path*`. Check Nuxt DevTools → Scripts or your Nitro server logs for the exact domains registered for your scripts. -Example for Vercel: +Example for [Vercel](https://vercel.com): ```json [vercel.json] { @@ -197,6 +252,10 @@ Example for Vercel: The same pattern applies to [Netlify](https://netlify.com) (`[[redirects]]` with `:splat`) and Cloudflare Pages (`_redirects` file). Only include rewrites for scripts you use. +::callout{type="warning"} +Platform-level rewrites bypass the privacy anonymization layer. The proxy handler only runs in a Nitro server runtime. +:: + ## Consent Integration First-party mode controls *where* requests go. [Consent triggers](/docs/guides/consent) control *when* scripts load. Use both: @@ -212,6 +271,55 @@ useScriptGoogleAnalytics({ ``` +### Third-Party Consent Managers + +For tools like OneTrust, CookieBot, or Osano, bind their consent signal to a reactive ref: + +```vue + +``` + +Or use `trigger: 'manual'` in your registry config and call `$script.load()`{lang="ts"} when consent is granted: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + // Infrastructure only, load manually after consent + googleAnalytics: { id: 'G-XXXXXX' }, + } + } +}) +``` + +```vue [app.vue] + +``` + +See the [Consent Management Guide](/docs/guides/consent) for full details on `useScriptTriggerConsent()`{lang="ts"}. + ::callout{type="info"} First-party mode does **not** bypass GDPR consent requirements. You still need user consent before loading tracking scripts. :: @@ -224,4 +332,5 @@ First-party mode does **not** bypass GDPR consent requirements. You still need u | Stale script | `rm -rf .nuxt/cache/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 first-party status | -| Per-script opt-out not working | For scripts with auto-inject (Plausible, PostHog, Umami, Rybbit, Databuddy), use `reverseProxyIntercept: false` in the registry config | +| Geo accuracy reduced | IP anonymization uses /24 subnets, which gives city-level accuracy. Set `privacy: false` per-script or globally to forward exact IPs | +| Per-script opt-out not working | For scripts with auto-inject (Plausible, PostHog, Umami, Rybbit, Databuddy), use `proxy: false` in the registry config | diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index e15deda7..212b401f 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -7,10 +7,51 @@ description: Configure Nuxt Scripts using your Nuxt Config. - Type: `ScriptRegistry`{lang="ts"} -Global registry scripts to load. +Configure which scripts to enable. Adding a script to the registry enables infrastructure (proxy routes, types, bundling, composable auto-imports) without auto-loading it. Add `trigger` to auto-load globally. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + // Infrastructure only (composable driven) + googleAnalytics: { id: 'G-XXXXXX' }, + // Infrastructure + global auto-load + plausibleAnalytics: { domain: 'mysite.com', trigger: 'onNuxtReady' }, + // Opt out of proxy + posthog: { apiKey: 'phc_xxx', proxy: false }, + // Testing stub (no script loaded, validation skipped) + clarity: 'mock', + // Disable a script + hotjar: false, + } + } +}) +``` + +Per-script capability flags (`trigger`, `proxy`, `bundle`, `partytown`) can be set at the top level of the config object alongside the script's input fields. See the [Script Registry](/scripts) for more details. +### Environment Variables + +Registry input fields resolve through Nuxt's `runtimeConfig.public` mechanism. Define fields under `runtimeConfig.public.scripts` and override via environment variables: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + runtimeConfig: { + public: { + scripts: { + googleAnalytics: { id: '' }, // NUXT_PUBLIC_SCRIPTS_GOOGLE_ANALYTICS_ID + posthog: { apiKey: '' }, // NUXT_PUBLIC_SCRIPTS_POSTHOG_API_KEY + crisp: { id: '' }, // NUXT_PUBLIC_SCRIPTS_CRISP_ID + } + } + } +}) +``` + +This keeps secrets out of your `nuxt.config.ts` and allows per-environment overrides. + ## `proxy`{lang="ts"} - Type: `false | { prefix?: string, privacy?: ProxyPrivacyInput }`{lang="ts"} @@ -18,7 +59,7 @@ See the [Script Registry](/scripts) for more details. Controls the reverse proxy infrastructure for routing third-party script requests through your domain. -By default, proxy is auto-enabled when any configured script has the `reverseProxyIntercept` capability. Set to `false` to globally disable all proxying. +By default, proxy is auto-enabled when any configured script has the `proxy` capability. Set to `false` to globally disable all proxying. ```ts [nuxt.config.ts] export default defineNuxtConfig({ @@ -39,16 +80,20 @@ See the [First-Party Guide](/docs/guides/first-party) for details on privacy con ### Per-Script Capabilities -Each registry script declares its own capabilities. Users can override per-script: +Presence in the registry enables infrastructure (proxy routes, types, bundling, composable auto-imports). Scripts only auto-load globally when `trigger` is explicitly set. All capability flags (`trigger`, `proxy`, `bundle`, `partytown`) can be set at the top level of the config object: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { registry: { + // Infrastructure only (composable driven) + googleAnalytics: { id: 'G-XXXXXX' }, + // Infrastructure + global auto-load + plausibleAnalytics: { domain: 'mysite.com', trigger: 'onNuxtReady' }, // Opt out of proxy for this script - plausibleAnalytics: [{ domain: 'mysite.com' }, { reverseProxyIntercept: false }], + posthog: { proxy: false }, // Enable Partytown for this script - fathomAnalytics: [{ site: 'XXXXX' }, { partytown: true }], + fathomAnalytics: { site: 'XXXXX', partytown: true, trigger: 'onNuxtReady' }, } } }) @@ -63,9 +108,9 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/partytown', '@nuxt/scripts'], scripts: { registry: { - plausibleAnalytics: [{ domain: 'example.com' }, { partytown: true }], - fathomAnalytics: [{ site: 'XXXXX' }, { partytown: true }], - umamiAnalytics: [{ websiteId: 'xxx' }, { partytown: true }], + plausibleAnalytics: { domain: 'example.com', partytown: true }, + fathomAnalytics: { site: 'XXXXX', partytown: true }, + umamiAnalytics: { websiteId: 'xxx', partytown: true }, } } }) @@ -118,6 +163,22 @@ Global scripts to load on all pages. This configuration applies to the [`useScri See the [Globals](/docs/guides/global) documentation for more details. +## `defaultScriptOptions.warmupStrategy`{lang="ts"} + +- Type: `false | 'preload' | 'preconnect' | 'dns-prefetch'`{lang="ts"} +- Default: `false` + +Controls how the browser warms up connections to script origins before the script loads: + +- `'preload'` inserts a `` tag, downloading the script early. Best for scripts that load soon after page load. +- `'preconnect'` establishes an early connection (DNS + TCP + TLS) to the script's origin. Use for scripts loaded later. +- `'dns-prefetch'` resolves just DNS. Lightest option, useful for scripts that may or may not load. +- `false` disables warmup entirely. Use when scripts are bundled (already served from your domain). + +::callout{type="info"} +When scripts are bundled via first-party mode, `preconnect` and `dns-prefetch` automatically fall back to `false` since the script is already served from your origin. +:: + ## `enabled`{lang="ts"} - Type: `boolean`{lang="ts"} diff --git a/docs/content/docs/4.migration-guide/1.v0-to-v1.md b/docs/content/docs/4.migration-guide/1.v0-to-v1.md index 69dd7a82..2c264404 100644 --- a/docs/content/docs/4.migration-guide/1.v0-to-v1.md +++ b/docs/content/docs/4.migration-guide/1.v0-to-v1.md @@ -27,7 +27,7 @@ export default defineNuxtConfig({ - **User IPs stay private**: third parties see your server's IP - **No third-party cookies**: requests are same-origin - **Works with ad blockers**: requests appear first-party -- Per-script opt-out: `reverseProxyIntercept: false` +- Per-script opt-out: `proxy: false` - Global disable: `proxy: false` Supported: Google Analytics, GTM, Meta Pixel, TikTok, Segment, Clarity, Hotjar, X/Twitter, Snapchat, Reddit. @@ -43,8 +43,8 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/partytown', '@nuxt/scripts'], scripts: { registry: { - plausibleAnalytics: [{ domain: 'example.com' }, { partytown: true }], - fathomAnalytics: [{ site: 'XXXXX' }, { partytown: true }], + plausibleAnalytics: { domain: 'example.com', partytown: true }, + fathomAnalytics: { site: 'XXXXX', partytown: true }, } } }) @@ -191,6 +191,77 @@ Auto-switches with `@nuxtjs/color-mode` or manual `color-mode` prop. Server-side geocoding proxy to reduce billing costs and hide API keys. Automatically enabled when `googleMaps` is in your registry. +## Registry Config Changes + +v1 redesigns how registry entries work. The key change: **presence enables infrastructure, `trigger` enables auto-loading**. + +### Config Migration + +| v0 | v1 | What changed | +|----|-----|--------------| +| `googleAnalytics: true` | `googleAnalytics: { id: 'G-xxx', trigger: 'onNuxtReady' }` | `true` is an alias for `{ trigger: 'onNuxtReady' }` (auto-load); use `{}` for infrastructure only | +| `googleAnalytics: 'proxy-only'` | `googleAnalytics: { id: 'G-xxx' }` | `'proxy-only'` removed; infrastructure only is now the default | +| `[{ id: '...' }, { reverseProxyIntercept: false }]` | `{ id: '...', proxy: false }` | `reverseProxyIntercept` renamed to `proxy`; flat syntax preferred | +| `[{ id: '...' }, { bundle: true }]` | `{ id: '...' }` | Bundling is auto-enabled via capabilities; no need to opt in | +| `googleAnalytics: 'mock'` | `googleAnalytics: 'mock'` | Unchanged; creates a stub for testing | + +### Flat Config Syntax + +v1 supports flat config syntax. `trigger`, `proxy`, `bundle`, and `partytown` can be set at the top level: + +```ts [v0 nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + googleAnalytics: [{ id: 'G-xxx' }, { trigger: 'onNuxtReady' }], + plausibleAnalytics: [{ domain: 'mysite.com' }, { proxy: false }], + } + } +}) +``` + +```ts [v1 nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + googleAnalytics: { id: 'G-xxx', trigger: 'onNuxtReady' }, + plausibleAnalytics: { domain: 'mysite.com', proxy: false }, + } + } +}) +``` + +### Environment Variables + +Registry config fields resolve through Nuxt's standard `runtimeConfig.public` mechanism. Add your script config under `runtimeConfig.public.scripts` and reference it via environment variables: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + runtimeConfig: { + public: { + scripts: { + googleAnalytics: { id: '' }, // NUXT_PUBLIC_SCRIPTS_GOOGLE_ANALYTICS_ID + posthog: { apiKey: '' }, // NUXT_PUBLIC_SCRIPTS_POSTHOG_API_KEY + } + } + } +}) +``` + +### Testing with `'mock'` + +Use `'mock'` to register a script stub without loading anything. This creates infrastructure (types, composable auto-imports) with `trigger: 'manual'` and validation disabled: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + registry: { + googleAnalytics: 'mock', + } + } +}) +``` + ## Breaking Changes ### PayPal SDK v6 ([#628](https://github.com/nuxt/scripts/pull/628)) diff --git a/docs/content/docs/5.releases/1.v1.md b/docs/content/docs/5.releases/1.v1.md index c359b58b..68b7cabc 100644 --- a/docs/content/docs/5.releases/1.v1.md +++ b/docs/content/docs/5.releases/1.v1.md @@ -29,7 +29,7 @@ export default defineNuxtConfig({ }) ``` -Per-script opt-out uses `reverseProxyIntercept: false` in the script's options. +Per-script opt-out uses `proxy: false` in the script's options. ### ⚡ Partytown Web Worker Support @@ -42,9 +42,9 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/partytown', '@nuxt/scripts'], scripts: { registry: { - plausibleAnalytics: [{ domain: 'example.com' }, { partytown: true }], - fathomAnalytics: [{ site: 'XXXXX' }, { partytown: true }], - umamiAnalytics: [{ websiteId: 'xxx' }, { partytown: true }], + plausibleAnalytics: { domain: 'example.com', partytown: true }, + fathomAnalytics: { site: 'XXXXX', partytown: true }, + umamiAnalytics: { websiteId: 'xxx', partytown: true }, } } // Forward array auto-configured per-script! diff --git a/package.json b/package.json index a8c30ed4..dc51af62 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,6 @@ "esbuild", "unimport", "#nuxt-scripts/types", - "#nuxt-scripts-validator", "posthog-js" ] }, diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 98fb6e76..0de32401 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -66,50 +66,51 @@ export default defineNuxtConfig({ scripts: { debug: true, registry: { - // trigger: 'manual' prevents all scripts from loading globally. - // Each page's composable call overrides the trigger to load only its script. + // v1 flat config syntax: presence = infrastructure, trigger = auto-load + // Scripts without `trigger` are composable-driven (load when composable is called) + // Scripts with `trigger` auto-load globally on every page - // Analytics - googleAnalytics: [{ id: 'G-TR58L0EF8P' }, { trigger: 'manual' }], - googleTagManager: [{ id: 'GTM-MWW974PF' }, { trigger: 'manual' }], - plausibleAnalytics: [{ domain: 'scripts.nuxt.com' }, { trigger: 'manual' }], - umamiAnalytics: [{ websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c' }, { trigger: 'manual' }], - fathomAnalytics: [{ site: 'BRDEJWKJ' }, { trigger: 'manual' }], - cloudflareWebAnalytics: [{ token: 'ade278253a19413c9bd923b079870902' }, { trigger: 'manual' }], - matomoAnalytics: [{ matomoUrl: 'https://cdn.matomo.cloud', siteId: '1' }, { trigger: 'manual' }], - vercelAnalytics: [true, { trigger: 'manual' }], - rybbitAnalytics: [{ siteId: '874' }, { trigger: 'manual' }], - databuddyAnalytics: [{ clientId: 'demo-client-123' }, { trigger: 'manual' }], - segment: [{ writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C' }, { trigger: 'manual' }], - posthog: [{ apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, { trigger: 'manual' }], + // Analytics — infrastructure only (composable driven on each page) + googleAnalytics: { id: 'G-TR58L0EF8P', trigger: 'manual' }, + googleTagManager: { id: 'GTM-MWW974PF', trigger: 'manual' }, + plausibleAnalytics: { domain: 'scripts.nuxt.com', trigger: 'manual' }, + umamiAnalytics: { websiteId: 'ae15c227-67e8-434a-831f-67e6df88bd6c', trigger: 'manual' }, + fathomAnalytics: { site: 'BRDEJWKJ', trigger: 'manual' }, + cloudflareWebAnalytics: { token: 'ade278253a19413c9bd923b079870902', trigger: 'manual' }, + matomoAnalytics: { matomoUrl: 'https://cdn.matomo.cloud', siteId: '1', trigger: 'manual' }, + vercelAnalytics: { trigger: 'manual' }, + rybbitAnalytics: { siteId: '874', trigger: 'manual' }, + databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' }, + segment: { writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C', trigger: 'manual' }, + posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' }, - // Pixels - metaPixel: [{ id: '3925006' }, { trigger: 'manual' }], - tiktokPixel: [{ id: 'TEST_PIXEL_ID' }, { trigger: 'manual' }], - xPixel: [{ id: 'ol7lz' }, { trigger: 'manual' }], - snapchatPixel: [{ id: '2295cbcc-cb3f-4727-8c09-1133b742722c' }, { trigger: 'manual' }], - redditPixel: [{ id: 'a2_ilz4u0kbdr3v' }, { trigger: 'manual' }], - googleAdsense: [{ client: 'ca-pub-1234567890' }, { trigger: 'manual' }], + // Pixels — infrastructure only + metaPixel: { id: '3925006', trigger: 'manual' }, + tiktokPixel: { id: 'TEST_PIXEL_ID', trigger: 'manual' }, + xPixel: { id: 'ol7lz', trigger: 'manual' }, + snapchatPixel: { id: '2295cbcc-cb3f-4727-8c09-1133b742722c', trigger: 'manual' }, + redditPixel: { id: 'a2_ilz4u0kbdr3v', trigger: 'manual' }, + googleAdsense: { client: 'ca-pub-1234567890', trigger: 'manual' }, // Heatmaps & Support - clarity: [{ id: 'mqk2m9dr2v' }, { trigger: 'manual' }], - hotjar: [{ id: 3925006, sv: 6 }, { trigger: 'manual' }], - intercom: [{ app_id: 'akg5rmxb' }, { trigger: 'manual' }], - crisp: [{ id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, { trigger: 'manual' }], + clarity: { id: 'mqk2m9dr2v', trigger: 'manual' }, + hotjar: { id: 3925006, sv: 6, trigger: 'manual' }, + intercom: { app_id: 'akg5rmxb', trigger: 'manual' }, + crisp: { id: 'b1021910-7ace-425a-9ef5-07f49e5ce417', trigger: 'manual' }, // Media - youtubePlayer: [true, { trigger: 'manual' }], - vimeoPlayer: [true, { trigger: 'manual' }], + youtubePlayer: { trigger: 'manual' }, + vimeoPlayer: { trigger: 'manual' }, // Maps - googleMaps: [true, { trigger: 'manual' }], + googleMaps: { trigger: 'manual' }, // Other - gravatar: [true, { trigger: 'manual' }], - carbonAds: [{ serve: 'CKYIE53L', placement: 'nuxtcom' }, { trigger: 'manual' }], - lemonSqueezy: [true, { trigger: 'manual' }], + gravatar: { trigger: 'manual' }, + carbonAds: { serve: 'CKYIE53L', placement: 'nuxtcom', trigger: 'manual' }, + lemonSqueezy: { trigger: 'manual' }, - // Excluded from first-party (proxy: false) — fingerprinting required: + // Excluded from first-party (no proxy capability) — fingerprinting required: // stripe, paypal, googleRecaptcha, googleSignIn }, }, diff --git a/playground/scripts/myCustomScript.ts b/playground/scripts/myCustomScript.ts index a871368a..3eef45f9 100644 --- a/playground/scripts/myCustomScript.ts +++ b/playground/scripts/myCustomScript.ts @@ -1,5 +1,5 @@ import { useRegistryScript } from '#nuxt-scripts/utils' -import { object, string } from '#nuxt-scripts-validator' +import { object, string } from 'valibot' import type { NuxtUseScriptOptions } from '#nuxt-scripts/types' export interface MyCustomScriptApi { diff --git a/src/first-party/index.ts b/src/first-party/index.ts index c6dc51e0..427dca58 100644 --- a/src/first-party/index.ts +++ b/src/first-party/index.ts @@ -1,6 +1,6 @@ export { generatePartytownResolveUrl } from './partytown-resolve' -export { buildProxyConfigsFromRegistry, getAllProxyConfigs, PRIVACY_FULL, PRIVACY_HEATMAP, PRIVACY_IP_ONLY, PRIVACY_NONE } from './proxy-configs' +export { buildProxyConfigsFromRegistry, PRIVACY_FULL, PRIVACY_HEATMAP, PRIVACY_IP_ONLY, PRIVACY_NONE } from './proxy-configs' export { resolveCapabilities } from './resolve-capabilities' -export { finalizeFirstParty, setupFirstParty } from './setup' +export { finalizeFirstParty, isProxyDisabled, setupFirstParty } from './setup' export type { FinalizeFirstPartyResult, FirstPartyConfig, FirstPartyDevtoolsData, FirstPartyDevtoolsScript } from './setup' export type { FirstPartyPrivacy, ProxyAutoInject, ProxyConfig, ProxyRewrite } from './types' diff --git a/src/first-party/proxy-configs.ts b/src/first-party/proxy-configs.ts index a03fccd9..500979cd 100644 --- a/src/first-party/proxy-configs.ts +++ b/src/first-party/proxy-configs.ts @@ -5,65 +5,39 @@ export { PRIVACY_FULL, PRIVACY_HEATMAP, PRIVACY_IP_ONLY, PRIVACY_NONE } from '.. /** * Build proxy configs from registry scripts. - * Each script with reverseProxyIntercept capability and domains gets a proxy config. + * Each script with proxy capability and domains gets a proxy config. * Scripts with proxyConfig alias inherit from the referenced script. */ -export function buildProxyConfigsFromRegistry(scripts: RegistryScript[]): Partial> { +export function buildProxyConfigsFromRegistry( + scripts: RegistryScript[], + scriptByKey?: Map, +): Partial> { const configs: Partial> = {} - const scriptByKey = new Map() - for (const script of scripts) { - if (script.registryKey) - scriptByKey.set(script.registryKey, script) + if (!scriptByKey) { + scriptByKey = new Map() + for (const script of scripts) { + if (script.registryKey) + scriptByKey.set(script.registryKey, script) + } } for (const script of scripts) { - if (!script.registryKey || !script.capabilities?.reverseProxyIntercept) + if (!script.registryKey || !script.capabilities?.proxy) continue - // Scripts with proxyConfig alias inherit from another script - if (script.proxyConfig) { - const source = scriptByKey.get(script.proxyConfig) - if (source?.domains) { - const domains = source.domains.map(d => typeof d === 'string' ? d : d.domain) - configs[script.registryKey] = { - domains, - privacy: source.privacy || { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, - autoInject: source.autoInject, - postProcess: source.postProcess, - } - } - continue - } - - if (!script.domains?.length) + // Resolve source: aliased scripts inherit from their proxyConfig target + const source = script.proxyConfig ? scriptByKey.get(script.proxyConfig) : script + if (!source?.domains?.length) continue - const domains = script.domains.map(d => typeof d === 'string' ? d : d.domain) configs[script.registryKey] = { - domains, - privacy: script.privacy || { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, - autoInject: script.autoInject, - postProcess: script.postProcess, + domains: source.domains.map(d => typeof d === 'string' ? d : d.domain), + privacy: source.privacy || { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, + autoInject: source.autoInject, + postProcess: source.postProcess, } } return configs } - -let _cachedConfigs: Partial> | undefined - -/** - * Get all proxy configs derived from the registry. - * Lazy-loads and caches on first call. Pass `scripts` to build from a specific registry snapshot. - */ -export async function getAllProxyConfigs(_proxyPrefix?: string, scripts?: RegistryScript[]): Promise>> { - if (scripts) - return buildProxyConfigsFromRegistry(scripts) - if (_cachedConfigs) - return _cachedConfigs - const { registry } = await import('../registry') - const registryScripts = await registry() - _cachedConfigs = buildProxyConfigsFromRegistry(registryScripts) - return _cachedConfigs -} diff --git a/src/first-party/resolve-capabilities.ts b/src/first-party/resolve-capabilities.ts index 87cda5c1..3d987145 100644 --- a/src/first-party/resolve-capabilities.ts +++ b/src/first-party/resolve-capabilities.ts @@ -4,7 +4,7 @@ import { logger } from '../logger' /** * Resolve the effective capabilities for a script by merging: * 1. Start with script.defaultCapability (or {} if absent) - * 2. Apply user overrides from scriptOptions (reverseProxyIntercept, bundle, partytown) + * 2. Apply user overrides from scriptOptions (proxy, bundle, partytown) * 3. Clamp to script.capabilities ceiling (user can't enable unsupported capabilities) * 4. Warn in dev if user tries to exceed ceiling */ @@ -20,7 +20,7 @@ export function resolveCapabilities( if (!scriptOptions) return resolved - const overrideKeys: (keyof ScriptCapabilities)[] = ['reverseProxyIntercept', 'bundle', 'partytown'] + const overrideKeys: (keyof ScriptCapabilities)[] = ['proxy', 'bundle', 'partytown'] for (const key of overrideKeys) { if (key in scriptOptions) { diff --git a/src/first-party/setup.ts b/src/first-party/setup.ts index 51cb415f..5fef739f 100644 --- a/src/first-party/setup.ts +++ b/src/first-party/setup.ts @@ -22,6 +22,29 @@ export interface ModuleProxyOptions { assets?: { prefix?: string } } +/** + * Check if proxy is opted-out for a specific script via its normalized registry entry + * and/or runtimeConfig. Checks input, scriptOptions, and runtimeConfig layers. + */ +export function isProxyDisabled( + registryKey: string, + registry?: NuxtConfigScriptRegistry, + runtimeConfig?: Record, +): boolean { + const entry = registry?.[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined + if (!entry) + return true + const [input, scriptOptions] = entry + if (input?.proxy === false || scriptOptions?.proxy === false) + return true + if (runtimeConfig) { + const rtEntry = (runtimeConfig.public?.scripts as Record | undefined)?.[registryKey] + if (rtEntry?.proxy === false) + return true + } + return false +} + /** * Setup first-party mode: register proxy handler unconditionally. * The handler rejects unknown domains at runtime, so it's safe to register always. @@ -40,7 +63,6 @@ export async function setupFirstParty( : undefined const assetsPrefix = config.assets?.prefix || '/_scripts/assets' - // enabled starts as !proxyDisabled; finalizeFirstParty may flip it to false if no scripts need proxy const firstParty: FirstPartyConfig = { enabled: !proxyDisabled, proxyPrefix, privacy, assetsPrefix, proxyConfigs: {} } if (!proxyDisabled) { @@ -66,16 +88,12 @@ export function applyAutoInject( registryKey: string, autoInject: ProxyAutoInject, ): void { - const entry = registry[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined - if (!entry) + if (isProxyDisabled(registryKey, registry, runtimeConfig)) return + const entry = registry[registryKey as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry const input = entry[0] - const scriptOptions = entry[1] - // Per-script reverseProxyIntercept opt-out (in input or scriptOptions) - if (input?.reverseProxyIntercept === false || scriptOptions?.reverseProxyIntercept === false) - return const rtScripts = runtimeConfig.public?.scripts as Record | undefined const rtEntry = rtScripts?.[registryKey] @@ -93,6 +111,8 @@ export function applyAutoInject( rtEntry[autoInject.configField] = value } +// -- Devtools types and helpers -- + export interface FirstPartyDevtoolsScript { registryKey: string label: string @@ -125,6 +145,61 @@ function computePrivacyLevel(privacy: Record): 'full' | 'partia return 'none' } +function buildDevtoolsEntry( + key: string, + script: RegistryScript, + configKey: string, + proxyConfig: ProxyConfig, +): FirstPartyDevtoolsScript { + const privacy = proxyConfig.privacy as Record + const normalizedPrivacy = { + ip: !!privacy.ip, + userAgent: !!privacy.userAgent, + language: !!privacy.language, + screen: !!privacy.screen, + timezone: !!privacy.timezone, + hardware: !!privacy.hardware, + } + const logo = script.logo + const logoStr = typeof logo === 'object' ? (logo.dark || logo.light) : (logo || '') + + return { + registryKey: key, + label: script.label || key, + logo: logoStr, + category: script.category || 'unknown', + configKey, + mechanism: script.src === false ? 'config-injection-proxy' : 'bundle-rewrite-intercept', + hasAutoInject: !!proxyConfig.autoInject, + autoInjectField: proxyConfig.autoInject?.configField, + hasPostProcess: !!proxyConfig.postProcess, + privacy: normalizedPrivacy, + privacyLevel: computePrivacyLevel(normalizedPrivacy), + domains: [...proxyConfig.domains], + } +} + +function buildDevtoolsData( + proxyPrefix: string, + privacyLabel: string, + scripts: FirstPartyDevtoolsScript[], +): FirstPartyDevtoolsData { + const allDomains = new Set() + for (const s of scripts) { + for (const d of s.domains) + allDomains.add(d) + } + return { + enabled: true, + proxyPrefix, + privacyMode: privacyLabel, + scripts, + totalDomains: allDomains.size, + } +} + +// -- Finalize -- + export interface FinalizeFirstPartyResult { proxyPrefix: string devtools?: FirstPartyDevtoolsData @@ -132,38 +207,28 @@ export interface FinalizeFirstPartyResult { /** * Finalize first-party setup inside modules:done. - * Uses pre-built proxyConfigs from setupFirstParty — no rebuild. - * Returns intercept rules (for partytown resolveUrl) and devtools data. + * Builds proxy configs, collects domain privacy mappings, registers intercept plugin. */ export function finalizeFirstParty(opts: { firstParty: FirstPartyConfig registry: NuxtConfigScriptRegistry | undefined registryScripts: RegistryScript[] + scriptByKey: Map nuxtOptions: { dev: boolean, runtimeConfig: Record } }): FinalizeFirstPartyResult { - const { firstParty, registryScripts, nuxtOptions } = opts + const { firstParty, registryScripts, scriptByKey, nuxtOptions } = opts const { proxyPrefix } = firstParty // Build proxy configs from registry (single source of truth) - const proxyConfigs = buildProxyConfigsFromRegistry(registryScripts) - // Update firstParty config for transform plugin consumers + const proxyConfigs = buildProxyConfigsFromRegistry(registryScripts, scriptByKey) firstParty.proxyConfigs = proxyConfigs const registryKeys = Object.keys(opts.registry || {}) - // Build lookup: registryKey → RegistryScript - const scriptByKey = new Map() - for (const script of registryScripts) { - if (script.registryKey) - scriptByKey.set(script.registryKey, script) - } - // Collect domain privacy mappings const domainPrivacy: Record = {} const unsupportedScripts: string[] = [] const unmatchedScripts: string[] = [] let totalDomains = 0 - - // Devtools: per-script data const devtoolsScripts: FirstPartyDevtoolsScript[] = [] for (const key of registryKeys) { @@ -173,16 +238,10 @@ export function finalizeFirstParty(opts: { continue } - // Skip scripts that don't support reverseProxyIntercept - if (!script.capabilities?.reverseProxyIntercept) + if (!script.capabilities?.proxy) continue - // Check per-script opt-out in registry config entry - // Entries are normalized to [input, scriptOptions?] tuple form - const registryEntry = opts.registry?.[key as keyof NuxtConfigScriptRegistry] as NormalizedRegistryEntry | undefined - const entryScriptOptions = registryEntry?.[1] - const entryInput = registryEntry?.[0] - if (entryScriptOptions?.reverseProxyIntercept === false || entryInput?.reverseProxyIntercept === false) + if (isProxyDisabled(key, opts.registry)) continue const configKey = (script.proxyConfig || key) as RegistryScriptKey @@ -193,45 +252,16 @@ export function finalizeFirstParty(opts: { continue } - // Map each domain to its privacy config for (const domain of proxyConfig.domains) { domainPrivacy[domain] = proxyConfig.privacy totalDomains++ } - // Auto-inject proxy endpoint config if (proxyConfig.autoInject && opts.registry) applyAutoInject(opts.registry, nuxtOptions.runtimeConfig, proxyPrefix, key, proxyConfig.autoInject) - // Build devtools entry - if (nuxtOptions.dev) { - const privacy = proxyConfig.privacy as Record - const normalizedPrivacy = { - ip: !!privacy.ip, - userAgent: !!privacy.userAgent, - language: !!privacy.language, - screen: !!privacy.screen, - timezone: !!privacy.timezone, - hardware: !!privacy.hardware, - } - const logo = script.logo - const logoStr = typeof logo === 'object' ? (logo.dark || logo.light) : (logo || '') - - devtoolsScripts.push({ - registryKey: key, - label: script.label || key, - logo: logoStr, - category: script.category || 'unknown', - configKey, - mechanism: script.src === false ? 'config-injection-proxy' : 'bundle-rewrite-intercept', - hasAutoInject: !!proxyConfig.autoInject, - autoInjectField: proxyConfig.autoInject?.configField, - hasPostProcess: !!proxyConfig.postProcess, - privacy: normalizedPrivacy, - privacyLevel: computePrivacyLevel(normalizedPrivacy), - domains: [...proxyConfig.domains], - }) - } + if (nuxtOptions.dev) + devtoolsScripts.push(buildDevtoolsEntry(key, script, configKey, proxyConfig)) } if (unmatchedScripts.length) { @@ -265,8 +295,7 @@ export function finalizeFirstParty(opts: { const privacyLabel = firstParty.privacy === undefined ? 'per-script' : typeof firstParty.privacy === 'boolean' ? (firstParty.privacy ? 'anonymize' : 'passthrough') : 'custom' if (totalDomains > 0 && nuxtOptions.dev) { - const scriptsCount = registryKeys.length - logger.success(`First-party mode enabled for ${scriptsCount} script(s), ${totalDomains} domain(s) proxied (privacy: ${privacyLabel})`) + logger.success(`First-party mode enabled for ${registryKeys.length} script(s), ${totalDomains} domain(s) proxied (privacy: ${privacyLabel})`) } // Warn for static presets @@ -286,23 +315,9 @@ export function finalizeFirstParty(opts: { ) } - // Build devtools data in dev mode - let devtools: FirstPartyDevtoolsData | undefined - if (nuxtOptions.dev) { - const allDomains = new Set() - for (const s of devtoolsScripts) { - for (const d of s.domains) - allDomains.add(d) - } - - devtools = { - enabled: true, - proxyPrefix, - privacyMode: privacyLabel, - scripts: devtoolsScripts, - totalDomains: allDomains.size, - } - } + const devtools = nuxtOptions.dev + ? buildDevtoolsData(proxyPrefix, privacyLabel, devtoolsScripts) + : undefined return { proxyPrefix, devtools } } diff --git a/src/module.ts b/src/module.ts index c4e82cd7..d60e113a 100644 --- a/src/module.ts +++ b/src/module.ts @@ -29,7 +29,7 @@ import { finalizeFirstParty, generatePartytownResolveUrl, setupFirstParty } from import { resolveCapabilities } from './first-party/resolve-capabilities' import { installNuxtModule } from './kit' import { logger } from './logger' -import { normalizeRegistryConfig } from './normalize' +import { extractRequiredFields, normalizeRegistryConfig } from './normalize' import { NuxtScriptsCheckScripts } from './plugins/check-scripts' import { NuxtScriptBundleTransformer } from './plugins/transform' import { registry } from './registry' @@ -164,7 +164,7 @@ export interface ModuleOptions { * Proxy configuration for routing third-party scripts through your domain. * * By default (undefined), proxy infrastructure is auto-registered when any - * configured script has `reverseProxyIntercept` capability enabled. Set to + * configured script has `proxy` capability enabled. Set to * `false` to globally disable all proxying. * * **Benefits:** @@ -173,7 +173,7 @@ export interface ModuleOptions { * - Works with ad blockers (requests appear first-party) * - Faster loads (no extra DNS lookups) * - * Per-script opt-out: set `reverseProxyIntercept: false` in a script's options. + * Per-script opt-out: set `proxy: false` in a script's options. * Per-script opt-in for partytown: set `partytown: true` in a script's options. * * @default undefined (auto-inferred from script capabilities) @@ -194,7 +194,8 @@ export interface ModuleOptions { privacy?: FirstPartyPrivacy } /** - * The registry of supported third-party scripts. Loads the scripts in globally using the default script options. + * The registry of supported third-party scripts. Presence enables infrastructure (proxy routes, types, bundling, composable auto-imports). + * Scripts only auto-load globally when `trigger` is explicitly set in the config object. */ registry?: NuxtConfigScriptRegistry /** @@ -306,7 +307,6 @@ export default defineNuxtModule({ async setup(config, nuxt) { const { resolvePath } = createResolver(import.meta.url) const { version, name } = await readPackageJSON(await resolvePath('../package.json')) - nuxt.options.alias['#nuxt-scripts-validator'] = await resolvePath(`./runtime/validation/${(nuxt.options.dev || nuxt.options._prepare) ? 'valibot' : 'mock'}`) nuxt.options.alias['#nuxt-scripts'] = await resolvePath('./runtime') logger.level = (config.debug || nuxt.options.debug) ? 4 : 3 if (!config.enabled) { @@ -441,6 +441,25 @@ export default defineNuxtModule({ } } + // Validate required fields using schemas from registry scripts + if (config.registry) { + for (const [key, entry] of Object.entries(config.registry)) { + if (!entry) + continue + const [input, scriptOptions] = entry as [Record, any?] + if (scriptOptions?.skipValidation) + continue + const script = scripts.find(s => s.registryKey === key) + if (!script?.schema) + continue + const requiredFields = extractRequiredFields(script.schema) + const missing = requiredFields.filter(f => !input[f]) + if (missing.length) { + logger.warn(`[nuxt-scripts] registry.${key}: missing required field${missing.length > 1 ? 's' : ''} ${missing.map(f => `'${f}'`).join(', ')}. The script infrastructure is registered but will not function without ${missing.length > 1 ? 'them' : 'it'}.`) + } + } + } + nuxt.hooks.hook('modules:done', async () => { const registryScripts = [...scripts] @@ -469,14 +488,16 @@ export default defineNuxtModule({ } const { renderedScript } = setupPublicAssetStrategy(config.assets) - // Resolve capabilities for each configured script and auto-detect partytown scripts - const partytownScripts = new Set() + // Build scriptByKey once, shared across capabilities resolution and first-party finalization const scriptByKey = new Map() for (const script of registryScripts) { if (script.registryKey) scriptByKey.set(script.registryKey, script) } + // Resolve capabilities for each configured script and auto-detect partytown scripts + const partytownScripts = new Set() + let anyNeedsProxy = false const registryKeys = Object.keys(config.registry || {}) for (const key of registryKeys) { @@ -493,7 +514,7 @@ export default defineNuxtModule({ const resolved = resolveCapabilities(script, mergedOverrides) - if (resolved.reverseProxyIntercept) + if (resolved.proxy) anyNeedsProxy = true if (resolved.partytown) { @@ -523,6 +544,7 @@ export default defineNuxtModule({ firstParty, registry: config.registry, registryScripts, + scriptByKey, nuxtOptions: nuxt.options, }) // Expose first-party data for devtools diff --git a/src/normalize.ts b/src/normalize.ts index 00bb6a22..c7ea5dee 100644 --- a/src/normalize.ts +++ b/src/normalize.ts @@ -1,18 +1,44 @@ +import type { NuxtUseScriptOptionsSerializable, RegistryScript } from './runtime/types' + /** Normalized registry entry: [input, scriptOptions?] tuple form. */ -export type NormalizedRegistryEntry = [input: Record, scriptOptions?: Record] +export type NormalizedRegistryEntry = [input: Record, scriptOptions?: NormalizedScriptOptions] + +export type NormalizedScriptOptions = Partial> & { + trigger?: NuxtUseScriptOptionsSerializable['trigger'] | 'manual' + skipValidation?: boolean +} + +/** Keys hoisted from the flat config object into scriptOptions during normalization. */ +const SCRIPT_OPTION_KEYS = ['trigger', 'proxy', 'bundle', 'partytown'] as const satisfies readonly (keyof NuxtUseScriptOptionsSerializable)[] + +/** + * Extract required field names from a valibot object schema. + * Fields wrapped in `optional()` have `type: 'optional'`; everything else is required. + */ +export function extractRequiredFields(schema: RegistryScript['schema']): string[] { + if (!schema) + return [] + return Object.entries(schema.entries) + .filter(([, field]) => field?.type !== 'optional') + .map(([key]) => key) +} /** * Normalize all registry config entries in-place to [input, scriptOptions?] tuple form. - * Eliminates the 4-shape polymorphism (true | 'mock' | object | [object, options]) - * so all downstream consumers handle a single shape. * - * - `true` → `[{}]` + * User-facing config shapes: + * - `false` → deleted * - `'mock'` → `[{}, { trigger: 'manual', skipValidation: true }]` - * - `{ id: '...' }` → `[{ id: '...' }]` - * - `[{ id: '...' }, opts]` → unchanged - * - falsy / empty array → deleted + * - `{}` → `[{}]` (infrastructure only, no auto-load) + * - `{ id: '...', trigger: 'onNuxtReady' }` → `[{ id: '...' }, { trigger: 'onNuxtReady' }]` + * - `{ id: '...', proxy: false }` → `[{ id: '...' }, { proxy: false }]` + * - `[input, scriptOptions]` → unchanged (internal/backwards compat) + * + * Aliases: + * - `true` → `[{}, { trigger: 'onNuxtReady' }]` (auto-load globally) + * - `'proxy-only'` → build error with migration message */ -export function normalizeRegistryConfig(registry: Record): void { +export function normalizeRegistryConfig(registry: Record): void { for (const key of Object.keys(registry)) { const entry = registry[key] if (!entry) { @@ -20,10 +46,17 @@ export function normalizeRegistryConfig(registry: Record): void { continue } if (entry === true) { - registry[key] = [{}] + registry[key] = [{}, { trigger: 'onNuxtReady' }] satisfies NormalizedRegistryEntry + continue } - else if (entry === 'mock') { - registry[key] = [{}, { trigger: 'manual', skipValidation: true }] + if (entry === 'proxy-only') { + throw new Error( + `[nuxt-scripts] registry.${key}: \`'proxy-only'\` is no longer supported. ` + + `Use \`{}\` instead (infrastructure only is now the default behavior).`, + ) + } + if (entry === 'mock') { + registry[key] = [{}, { trigger: 'manual', skipValidation: true }] satisfies NormalizedRegistryEntry } else if (Array.isArray(entry)) { if (!entry[0] && !entry[1]) { @@ -34,7 +67,24 @@ export function normalizeRegistryConfig(registry: Record): void { entry[0] = {} } else if (typeof entry === 'object') { - registry[key] = [entry] + const { scriptOptions, ...rest } = entry as Record + const input: Record = {} + const mergedScriptOptions: Record = {} + + // Apply legacy scriptOptions first so top-level flags take precedence + if (scriptOptions && typeof scriptOptions === 'object') + Object.assign(mergedScriptOptions, scriptOptions) + + for (const [k, v] of Object.entries(rest)) { + if ((SCRIPT_OPTION_KEYS as readonly string[]).includes(k)) + mergedScriptOptions[k] = v + else + input[k] = v + } + + registry[key] = Object.keys(mergedScriptOptions).length > 0 + ? [input, mergedScriptOptions] + : [input] } else { delete registry[key] diff --git a/src/plugins/rewrite-ast.ts b/src/plugins/rewrite-ast.ts index 6aee7a28..d60e3259 100644 --- a/src/plugins/rewrite-ast.ts +++ b/src/plugins/rewrite-ast.ts @@ -277,8 +277,8 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites // Canvas fingerprinting neutralization — gated on hardware privacy flag. // Only scripts with hardware anonymization enabled get canvas neutralized. - // Scripts with PRIVACY_NONE (e.g. Plausible, PostHog) skip this since they - // don't canvas fingerprint and may use canvas APIs legitimately. + // Scripts without hardware anonymization skip this since they don't + // canvas fingerprint and may use canvas APIs legitimately. const shouldNeutralizeCanvas = options?.neutralizeCanvas !== false const canvasPropName = shouldNeutralizeCanvas && callee?.type === 'MemberExpression' ? (callee.computed diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 44948321..d7a15a13 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -398,27 +398,27 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti canBundle = bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true' forceDownload = bundleValue === 'force' } - // Check for per-script reverseProxyIntercept opt-out + // Check for per-script proxy opt-out // Check in three locations: - // 1. In scriptOptions (nested) - useScriptGA({ scriptOptions: { reverseProxyIntercept: false } }) - // 2. In second argument (direct) - useScript('...', { reverseProxyIntercept: false }) - // 3. In first argument's properties - useScript({ src: '...', reverseProxyIntercept: false }) + // 1. In scriptOptions (nested) - useScriptGA({ scriptOptions: { proxy: false } }) + // 2. In second argument (direct) - useScript('...', { proxy: false }) + // 3. In first argument's properties - useScript({ src: '...', proxy: false }) const rpiOption = scriptOptions?.value.properties?.find((prop: any) => { - return prop.type === 'Property' && prop.key?.name === 'reverseProxyIntercept' && prop.value.type === 'Literal' + return prop.type === 'Property' && prop.key?.name === 'proxy' && prop.value.type === 'Literal' }) let firstPartyOptOut = rpiOption?.value.value === false if (!firstPartyOptOut && node.arguments[1]?.type === 'ObjectExpression') { const secondArgProp = node.arguments[1].properties.find( - (p: any) => p.type === 'Property' && p.key?.name === 'reverseProxyIntercept' && p.value.type === 'Literal', + (p: any) => p.type === 'Property' && p.key?.name === 'proxy' && p.value.type === 'Literal', ) firstPartyOptOut = secondArgProp?.value.value === false } if (!firstPartyOptOut && node.arguments[0]?.type === 'ObjectExpression') { const firstArgProp = node.arguments[0].properties.find( - (p: any) => p.type === 'Property' && p.key?.name === 'reverseProxyIntercept' && p.value.type === 'Literal', + (p: any) => p.type === 'Property' && p.key?.name === 'proxy' && p.value.type === 'Literal', ) firstPartyOptOut = firstArgProp?.value.value === false } @@ -427,7 +427,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // Get proxy rewrites if first-party is enabled, not opted out, and script supports it // Use script's proxyConfig alias if defined, otherwise fall back to registry key const script = options.scripts?.find(s => s.import.name === fnName) - const hasReverseProxy = script?.capabilities?.reverseProxyIntercept + const hasReverseProxy = script?.capabilities?.proxy const proxyConfigKey = hasReverseProxy ? (script?.proxyConfig || registryKey) : undefined const proxyConfig = !firstPartyOptOut && proxyConfigKey ? options.proxyConfigs?.[proxyConfigKey] diff --git a/src/registry.ts b/src/registry.ts index 75bdf05f..95c5bda0 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -10,9 +10,43 @@ import type { RybbitAnalyticsInput } from './runtime/registry/rybbit-analytics' import type { SegmentInput } from './runtime/registry/segment' import type { TikTokPixelInput } from './runtime/registry/tiktok-pixel' import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' -import type { RegistryScript, ScriptCapabilities } from './runtime/types' +import type { RegistryScript, RegistryScriptKey, RegistryScriptServerHandler, ScriptCapabilities } from './runtime/types' import { joinURL, withBase, withQuery } from 'ufo' import { LOGOS } from './registry-logos' +import { + BingUetOptions, + BlueskyEmbedOptions, + ClarityOptions, + CloudflareWebAnalyticsOptions, + CrispOptions, + DatabuddyAnalyticsOptions, + FathomAnalyticsOptions, + GoogleAdsenseOptions, + GoogleAnalyticsOptions, + GoogleMapsOptions, + GoogleRecaptchaOptions, + GoogleSignInOptions, + GoogleTagManagerOptions, + GravatarOptions, + HotjarOptions, + InstagramEmbedOptions, + IntercomOptions, + MatomoAnalyticsOptions, + MetaPixelOptions, + MixpanelAnalyticsOptions, + NpmOptions, + PostHogOptions, + RedditPixelOptions, + RybbitAnalyticsOptions, + SegmentOptions, + SnapTrPixelOptions, + StripeOptions, + TikTokPixelOptions, + UmamiAnalyticsOptions, + VercelAnalyticsOptions, + XEmbedOptions, + XPixelOptions, +} from './runtime/registry/schemas' // avoid nuxt/kit dependency here so we can use in docs @@ -26,29 +60,81 @@ export const PRIVACY_IP_ONLY: ProxyPrivacyInput = { ip: true, userAgent: false, const FATHOM_SELF_HOSTED_RE = /\.src\.indexOf\("cdn\.usefathom\.com"\)\s*<\s*0/ const RYBBIT_HOST_SPLIT_RE = /\w+\.split\(["']\/script\.js["']\)\[0\]/g -// Common capability presets for registry scripts. -// partytown: true only for scripts with known PARTYTOWN_FORWARDS in module.ts. -const CAP_FULL_PT: ScriptCapabilities = { bundle: true, reverseProxyIntercept: true, partytown: true } -const CAP_FULL: ScriptCapabilities = { bundle: true, reverseProxyIntercept: true } +// Capability presets. partytown: true only for scripts with known PARTYTOWN_FORWARDS in module.ts. +const CAP_FULL_PT: ScriptCapabilities = { bundle: true, proxy: true, partytown: true } +const CAP_FULL: ScriptCapabilities = { bundle: true, proxy: true } const CAP_BUNDLE_PT: ScriptCapabilities = { bundle: true, partytown: true } const CAP_BUNDLE: ScriptCapabilities = { bundle: true } -const CAP_PROXY: ScriptCapabilities = { reverseProxyIntercept: true } -const DEF_FULL: ScriptCapabilities = { bundle: true, reverseProxyIntercept: true } -const DEF_BUNDLE: ScriptCapabilities = { bundle: true } -const DEF_PROXY: ScriptCapabilities = { reverseProxyIntercept: true } +const CAP_PROXY: ScriptCapabilities = { proxy: true } + +// -- defineRegistryScript helper -- + +const UPPER_CASE_RE = /[A-Z]/g + +function camelToKebab(str: string): string { + return str.replace(UPPER_CASE_RE, m => `-${m.toLowerCase()}`) +} + +/** Derive defaultCapability from capabilities: same flags minus partytown. */ +function deriveDefaultCapability(capabilities?: ScriptCapabilities): ScriptCapabilities | undefined { + if (!capabilities) + return undefined + const { partytown: _, ...rest } = capabilities + return rest +} + +type RegistryScriptDef = Omit & { + /** Override auto-derived composable name, or false to skip composable registration */ + composableName?: string | false + /** Override auto-derived defaultCapability (defaults to capabilities minus partytown) */ + defaultCapability?: ScriptCapabilities + /** Server handlers with unresolved paths (resolved automatically by the helper) */ + serverHandlers?: RegistryScriptServerHandler[] +} + +async function defineScript( + resolve: (path: string, opts?: ResolvePathOptions) => Promise, + registryKey: string, + script: RegistryScriptDef, +): Promise { + const { composableName, defaultCapability, serverHandlers, ...rest } = script + const result: RegistryScript = { + registryKey: registryKey as RegistryScriptKey, + logo: LOGOS[registryKey as keyof typeof LOGOS], + defaultCapability: defaultCapability || deriveDefaultCapability(rest.capabilities), + ...rest, + } + + if (composableName !== false) { + result.import = { + name: composableName || `useScript${registryKey.charAt(0).toUpperCase()}${registryKey.slice(1)}`, + from: await resolve(`./runtime/registry/${camelToKebab(registryKey)}`), + } + } + + if (serverHandlers) { + result.serverHandlers = await Promise.all( + serverHandlers.map(async h => ({ ...h, handler: await resolve(h.handler) })), + ) + } + + return result +} + +// -- Registry -- export async function registry(resolve?: (path: string, opts?: ResolvePathOptions | undefined) => Promise): Promise { resolve = resolve || ((s: string) => Promise.resolve(s)) + const def = (key: string, script: RegistryScriptDef) => defineScript(resolve, key, script) - return [ - { - registryKey: 'plausibleAnalytics', + return Promise.all([ + // analytics + def('plausibleAnalytics', { label: 'Plausible Analytics', category: 'analytics', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['plausible.io'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, autoInject: { configField: 'endpoint', computeValue: proxyPrefix => `${proxyPrefix}/plausible.io/api/event` }, scriptBundling: (options?: PlausibleAnalyticsInput) => { if (options?.scriptId) @@ -56,51 +142,34 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption const extensions = Array.isArray(options?.extension) ? options.extension.join('.') : [options?.extension] return options?.extension ? `https://plausible.io/js/script.${extensions}.js` : 'https://plausible.io/js/script.js' }, - logo: LOGOS.plausibleAnalytics, - import: { - name: 'useScriptPlausibleAnalytics', - from: await resolve('./runtime/registry/plausible-analytics'), - }, - }, - { - registryKey: 'cloudflareWebAnalytics', + }), + def('cloudflareWebAnalytics', { + schema: CloudflareWebAnalyticsOptions, label: 'Cloudflare Web Analytics', src: 'https://static.cloudflareinsights.com/beacon.min.js', category: 'analytics', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['static.cloudflareinsights.com', 'cloudflareinsights.com'], - privacy: PRIVACY_NONE, - logo: LOGOS.cloudflareWebAnalytics, - import: { - name: 'useScriptCloudflareWebAnalytics', - from: await resolve('./runtime/registry/cloudflare-web-analytics'), - }, - }, - { - registryKey: 'vercelAnalytics', + privacy: PRIVACY_IP_ONLY, + }), + def('vercelAnalytics', { + schema: VercelAnalyticsOptions, label: 'Vercel Analytics', src: 'https://va.vercel-scripts.com/v1/script.js', category: 'analytics', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['va.vercel-scripts.com'], - privacy: PRIVACY_NONE, - logo: LOGOS.vercelAnalytics, - import: { - name: 'useScriptVercelAnalytics', - from: await resolve('./runtime/registry/vercel-analytics'), - }, - }, - { - registryKey: 'posthog', + privacy: PRIVACY_IP_ONLY, + }), + def('posthog', { + composableName: 'useScriptPostHog', + schema: PostHogOptions, label: 'PostHog', src: false, scriptBundling: false, capabilities: CAP_PROXY, - defaultCapability: DEF_PROXY, domains: ['us-assets.i.posthog.com', 'us.i.posthog.com', 'eu-assets.i.posthog.com', 'eu.i.posthog.com'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, autoInject: { configField: 'apiHost', computeValue: (proxyPrefix, config) => { @@ -110,50 +179,32 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, }, category: 'analytics', - logo: LOGOS.posthog, - import: { - name: 'useScriptPostHog', - from: await resolve('./runtime/registry/posthog'), - }, - }, - { - registryKey: 'fathomAnalytics', + }), + def('fathomAnalytics', { + schema: FathomAnalyticsOptions, label: 'Fathom Analytics', src: 'https://cdn.usefathom.com/script.js', category: 'analytics', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['cdn.usefathom.com'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, postProcess(output) { return output.replace(FATHOM_SELF_HOSTED_RE, '.src.indexOf("cdn.usefathom.com")<-1') }, - logo: LOGOS.fathomAnalytics, - import: { - name: 'useScriptFathomAnalytics', - from: await resolve('./runtime/registry/fathom-analytics'), - }, - }, - { - registryKey: 'matomoAnalytics', + }), + def('matomoAnalytics', { + schema: MatomoAnalyticsOptions, label: 'Matomo Analytics', - scriptBundling: false, // breaks script + scriptBundling: false, capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['cdn.matomo.cloud'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, category: 'analytics', - logo: LOGOS.matomoAnalytics, - import: { - name: 'useScriptMatomoAnalytics', - from: await resolve('./runtime/registry/matomo-analytics'), - }, - }, - { - registryKey: 'rybbitAnalytics', + }), + def('rybbitAnalytics', { + schema: RybbitAnalyticsOptions, label: 'Rybbit Analytics', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['app.rybbit.io'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, autoInject: { configField: 'analyticsHost', computeValue: proxyPrefix => `${proxyPrefix}/app.rybbit.io/api` }, postProcess(output, rewrites) { const rybbitRewrite = rewrites.find(r => r.from === 'app.rybbit.io') @@ -162,436 +213,278 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption return output }, scriptBundling: (options?: RybbitAnalyticsInput) => { - // SDK reads document.currentScript.src to derive API host, but AST rewrite - // patches this at build time (see RYBBIT_HOST_SPLIT_RE in rewrite-ast.ts). - // Always download from the real host — if analyticsHost is a proxy path - // (set by auto-inject), fall back to the default. const host = options?.analyticsHost if (host && !host.startsWith('/')) return `${host}/script.js` return 'https://app.rybbit.io/api/script.js' }, category: 'analytics', - logo: LOGOS.rybbitAnalytics, - import: { - name: 'useScriptRybbitAnalytics', - from: await resolve('./runtime/registry/rybbit-analytics'), - }, - }, - { - registryKey: 'databuddyAnalytics', + }), + def('databuddyAnalytics', { + schema: DatabuddyAnalyticsOptions, label: 'Databuddy Analytics', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['cdn.databuddy.cc', 'basket.databuddy.cc'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, autoInject: { configField: 'apiUrl', computeValue: proxyPrefix => `${proxyPrefix}/basket.databuddy.cc` }, scriptBundling: () => 'https://cdn.databuddy.cc/databuddy.js', category: 'analytics', - logo: LOGOS.databuddyAnalytics, - import: { - name: 'useScriptDatabuddyAnalytics', - from: await resolve('./runtime/registry/databuddy-analytics'), - }, - }, - { - registryKey: 'segment', + }), + def('segment', { + schema: SegmentOptions, label: 'Segment', - capabilities: CAP_BUNDLE_PT, // reverseProxyIntercept fails: SDK constructs API URLs dynamically - defaultCapability: DEF_BUNDLE, + capabilities: CAP_BUNDLE_PT, scriptBundling: (options?: SegmentInput) => { return joinURL('https://cdn.segment.com/analytics.js/v1', options?.writeKey || '', 'analytics.min.js') }, - logo: LOGOS.segment, category: 'analytics', - import: { - name: 'useScriptSegment', - from: await resolve('./runtime/registry/segment'), - }, - }, - { - registryKey: 'mixpanelAnalytics', + }), + def('mixpanelAnalytics', { + schema: MixpanelAnalyticsOptions, label: 'Mixpanel', capabilities: CAP_BUNDLE_PT, - defaultCapability: DEF_BUNDLE, scriptBundling: (options?: MixpanelAnalyticsInput) => { if (!options?.token) return false return 'https://cdn.mxpnl.com/libs/mixpanel-2-latest.min.js' }, category: 'analytics', - logo: LOGOS.mixpanelAnalytics, - import: { - name: 'useScriptMixpanelAnalytics', - from: await resolve('./runtime/registry/mixpanel-analytics'), - }, - }, - { - registryKey: 'bingUet', + }), + // ad + def('bingUet', { + schema: BingUetOptions, label: 'Bing UET', src: 'https://bat.bing.com/bat.js', capabilities: CAP_BUNDLE_PT, - defaultCapability: DEF_BUNDLE, category: 'ad', - logo: LOGOS.bingUet, - import: { - name: 'useScriptBingUet', - from: await resolve('./runtime/registry/bing-uet'), - }, - }, - { - registryKey: 'metaPixel', + }), + def('metaPixel', { + schema: MetaPixelOptions, label: 'Meta Pixel', src: 'https://connect.facebook.net/en_US/fbevents.js', category: 'ad', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['connect.facebook.net', 'www.facebook.com', 'facebook.com', 'pixel.facebook.com'], privacy: PRIVACY_FULL, - logo: LOGOS.metaPixel, - import: { - name: 'useScriptMetaPixel', - from: await resolve('./runtime/registry/meta-pixel'), - }, - }, - { - registryKey: 'xPixel', + }), + def('xPixel', { + schema: XPixelOptions, label: 'X Pixel', src: 'https://static.ads-twitter.com/uwt.js', category: 'ad', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['analytics.twitter.com', 'static.ads-twitter.com', 't.co'], privacy: PRIVACY_FULL, - logo: LOGOS.xPixel, - import: { - name: 'useScriptXPixel', - from: await resolve('./runtime/registry/x-pixel'), - }, - }, - { - registryKey: 'tiktokPixel', + }), + def('tiktokPixel', { + composableName: 'useScriptTikTokPixel', + schema: TikTokPixelOptions, label: 'TikTok Pixel', category: 'ad', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['analytics.tiktok.com'], privacy: PRIVACY_FULL, - logo: LOGOS.tiktokPixel, - import: { - name: 'useScriptTikTokPixel', - from: await resolve('./runtime/registry/tiktok-pixel'), - }, scriptBundling(options?: TikTokPixelInput) { if (!options?.id) return false return withQuery('https://analytics.tiktok.com/i18n/pixel/events.js', { sdkid: options.id, lib: 'ttq' }) }, - }, - { - registryKey: 'snapchatPixel', + }), + def('snapchatPixel', { + schema: SnapTrPixelOptions, label: 'Snapchat Pixel', src: 'https://sc-static.net/scevent.min.js', category: 'ad', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['sc-static.net', 'tr.snapchat.com', 'pixel.tapad.com'], privacy: PRIVACY_FULL, - logo: LOGOS.snapchatPixel, - import: { - name: 'useScriptSnapchatPixel', - from: await resolve('./runtime/registry/snapchat-pixel'), - }, - }, - { - registryKey: 'redditPixel', + }), + def('redditPixel', { + schema: RedditPixelOptions, label: 'Reddit Pixel', src: 'https://www.redditstatic.com/ads/pixel.js', category: 'ad', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['www.redditstatic.com', 'alb.reddit.com', 'pixel-config.reddit.com'], privacy: PRIVACY_FULL, - logo: LOGOS.redditPixel, - import: { - name: 'useScriptRedditPixel', - from: await resolve('./runtime/registry/reddit-pixel'), - }, - }, - // ad - { - registryKey: 'googleAdsense', + }), + def('googleAdsense', { + schema: GoogleAdsenseOptions, label: 'Google Adsense', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, - proxyConfig: 'googleAnalytics', // shares GA's domains/privacy + proxyConfig: 'googleAnalytics', scriptBundling: (options?: GoogleAdsenseInput) => { - if (!options?.client) { + if (!options?.client) return false - } - return withQuery('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { - client: options?.client, - }) + return withQuery('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js', { client: options?.client }) }, category: 'ad', - logo: LOGOS.googleAdsense, - import: { - name: 'useScriptGoogleAdsense', - from: await resolve('./runtime/registry/google-adsense'), - }, - }, - { - registryKey: 'carbonAds', + }), + def('carbonAds', { + composableName: false, label: 'Carbon Ads', scriptBundling: false, capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['cdn.carbonads.com'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, category: 'ad', - logo: LOGOS.carbonAds, - }, + }), // support - { - registryKey: 'intercom', + def('intercom', { + schema: IntercomOptions, label: 'Intercom', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['widget.intercom.io', 'api-iam.intercom.io', 'api-iam.eu.intercom.io', 'api-iam.au.intercom.io', 'js.intercomcdn.com', 'downloads.intercomcdn.com', 'video-messages.intercomcdn.com'], privacy: PRIVACY_IP_ONLY, scriptBundling(options?: IntercomInput) { - if (!options?.app_id) { + if (!options?.app_id) return false - } return joinURL(`https://widget.intercom.io/widget`, options?.app_id || '') }, - logo: LOGOS.intercom, category: 'support', - import: { - name: 'useScriptIntercom', - from: await resolve('./runtime/registry/intercom'), - }, - }, - { - registryKey: 'hotjar', + }), + def('hotjar', { + schema: HotjarOptions, label: 'Hotjar', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['static.hotjar.com', 'script.hotjar.com', 'vars.hotjar.com', 'in.hotjar.com', 'vc.hotjar.com', 'vc.hotjar.io', 'metrics.hotjar.io', 'insights.hotjar.com', 'ask.hotjar.io', 'events.hotjar.io', 'identify.hotjar.com', 'surveystats.hotjar.io'], privacy: PRIVACY_HEATMAP, scriptBundling(options?: HotjarInput) { - if (!options?.id) { + if (!options?.id) return false - } - return withQuery(`https://static.hotjar.com/c/hotjar-${options?.id || ''}.js`, { - sv: options?.sv || '6', - }) + return withQuery(`https://static.hotjar.com/c/hotjar-${options?.id || ''}.js`, { sv: options?.sv || '6' }) }, - logo: LOGOS.hotjar, category: 'analytics', - import: { - name: 'useScriptHotjar', - from: await resolve('./runtime/registry/hotjar'), - }, - }, - { - registryKey: 'clarity', + }), + def('clarity', { + schema: ClarityOptions, label: 'Clarity', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['www.clarity.ms', 'scripts.clarity.ms', 'd.clarity.ms', 'e.clarity.ms', 'k.clarity.ms'], privacy: PRIVACY_HEATMAP, scriptBundling(options?: ClarityInput) { - if (!options?.id) { + if (!options?.id) return false - } return `https://www.clarity.ms/tag/${options?.id}` }, - logo: LOGOS.clarity, category: 'analytics', - import: { - name: 'useScriptClarity', - from: await resolve('./runtime/registry/clarity'), - }, - }, + }), // payments - { - registryKey: 'stripe', + def('stripe', { + schema: StripeOptions, label: 'Stripe', - scriptBundling: false, // needs fingerprinting for fraud detection + scriptBundling: false, category: 'payments', - logo: LOGOS.stripe, - import: { - name: 'useScriptStripe', - from: await resolve('./runtime/registry/stripe'), - }, - }, - { - registryKey: 'lemonSqueezy', + }), + def('lemonSqueezy', { label: 'Lemon Squeezy', - src: false, // should not be bundled + src: false, capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['assets.lemonsqueezy.com'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, category: 'payments', - logo: LOGOS.lemonSqueezy, - import: { - name: 'useScriptLemonSqueezy', - from: await resolve('./runtime/registry/lemon-squeezy'), - }, - }, - { - registryKey: 'paypal', + }), + def('paypal', { + composableName: 'useScriptPayPal', label: 'PayPal', - src: false, // needs fingerprinting for fraud detection + src: false, category: 'payments', - logo: LOGOS.paypal, - import: { - name: 'useScriptPayPal', - from: await resolve('./runtime/registry/paypal'), - }, - }, + }), // video - { - registryKey: 'vimeoPlayer', + def('vimeoPlayer', { label: 'Vimeo Player', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['player.vimeo.com'], privacy: PRIVACY_IP_ONLY, category: 'video', - logo: LOGOS.vimeoPlayer, - import: { - name: 'useScriptVimeoPlayer', - from: await resolve('./runtime/registry/vimeo-player'), - }, - }, - { - registryKey: 'youtubePlayer', + }), + def('youtubePlayer', { + composableName: 'useScriptYouTubePlayer', label: 'YouTube Player', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['www.youtube.com'], privacy: PRIVACY_IP_ONLY, category: 'video', - logo: LOGOS.youtubePlayer, - import: { - name: 'useScriptYouTubePlayer', - from: await resolve('./runtime/registry/youtube-player'), - }, - }, - { - registryKey: 'googleMaps', + }), + // content + def('googleMaps', { + schema: GoogleMapsOptions, label: 'Google Maps', category: 'content', - logo: LOGOS.googleMaps, - import: { - name: 'useScriptGoogleMaps', - from: await resolve('./runtime/registry/google-maps'), - }, serverHandlers: [ - { route: '/_scripts/proxy/google-static-maps', handler: await resolve('./runtime/server/google-static-maps-proxy') }, - { route: '/_scripts/proxy/google-maps-geocode', handler: await resolve('./runtime/server/google-maps-geocode-proxy') }, + { route: '/_scripts/proxy/google-static-maps', handler: './runtime/server/google-static-maps-proxy' }, + { route: '/_scripts/proxy/google-maps-geocode', handler: './runtime/server/google-maps-geocode-proxy' }, ], - }, - { - registryKey: 'blueskyEmbed', + }), + def('blueskyEmbed', { + composableName: false, + schema: BlueskyEmbedOptions, label: 'Bluesky Embed', category: 'content', - logo: LOGOS.blueskyEmbed, serverHandlers: [ - { route: '/_scripts/embed/bluesky', handler: await resolve('./runtime/server/bluesky-embed') }, - { route: '/_scripts/embed/bluesky-image', handler: await resolve('./runtime/server/bluesky-embed-image') }, + { route: '/_scripts/embed/bluesky', handler: './runtime/server/bluesky-embed' }, + { route: '/_scripts/embed/bluesky-image', handler: './runtime/server/bluesky-embed-image' }, ], - }, - { - registryKey: 'instagramEmbed', + }), + def('instagramEmbed', { + composableName: false, + schema: InstagramEmbedOptions, label: 'Instagram Embed', category: 'content', - logo: LOGOS.instagramEmbed, serverHandlers: [ - { route: '/_scripts/embed/instagram', handler: await resolve('./runtime/server/instagram-embed') }, - { route: '/_scripts/embed/instagram-image', handler: await resolve('./runtime/server/instagram-embed-image') }, - { route: '/_scripts/embed/instagram-asset', handler: await resolve('./runtime/server/instagram-embed-asset') }, + { route: '/_scripts/embed/instagram', handler: './runtime/server/instagram-embed' }, + { route: '/_scripts/embed/instagram-image', handler: './runtime/server/instagram-embed-image' }, + { route: '/_scripts/embed/instagram-asset', handler: './runtime/server/instagram-embed-asset' }, ], - }, - { - registryKey: 'xEmbed', + }), + def('xEmbed', { + composableName: false, + schema: XEmbedOptions, label: 'X Embed', category: 'content', - logo: LOGOS.xEmbed, serverHandlers: [ - { route: '/_scripts/embed/x', handler: await resolve('./runtime/server/x-embed') }, - { route: '/_scripts/embed/x-image', handler: await resolve('./runtime/server/x-embed-image') }, + { route: '/_scripts/embed/x', handler: './runtime/server/x-embed' }, + { route: '/_scripts/embed/x-image', handler: './runtime/server/x-embed-image' }, ], - }, - // chat - { - registryKey: 'crisp', + }), + // support + def('crisp', { + schema: CrispOptions, label: 'Crisp', - capabilities: CAP_BUNDLE, // reverseProxyIntercept fails: SDK loads secondary scripts at runtime - defaultCapability: DEF_BUNDLE, + capabilities: CAP_BUNDLE, category: 'support', - logo: LOGOS.crisp, - import: { - name: 'useScriptCrisp', - from: await resolve('./runtime/registry/crisp'), - }, - }, + }), // cdn - { - registryKey: 'npm', + def('npm', { + schema: NpmOptions, label: 'NPM', scriptBundling(options?: NpmInput) { return withBase(options?.file || '', `https://unpkg.com/${options?.packageName || ''}@${options?.version || 'latest'}`) }, - logo: LOGOS.npm, category: 'cdn', - import: { - name: 'useScriptNpm', - // key is based on package name - from: await resolve('./runtime/registry/npm'), - }, - }, - { - registryKey: 'googleRecaptcha', + }), + // utility + def('googleRecaptcha', { + schema: GoogleRecaptchaOptions, label: 'Google reCAPTCHA', - scriptBundling: false, // needs fingerprinting for bot detection + scriptBundling: false, category: 'utility', - logo: LOGOS.googleRecaptcha, - import: { - name: 'useScriptGoogleRecaptcha', - from: await resolve('./runtime/registry/google-recaptcha'), - }, - }, - { - registryKey: 'googleSignIn', + }), + def('googleSignIn', { + schema: GoogleSignInOptions, label: 'Google Sign-In', src: 'https://accounts.google.com/gsi/client', - scriptBundling: false, // CORS prevents bundling, needs fingerprinting for auth + scriptBundling: false, category: 'utility', - logo: LOGOS.googleSignIn, - import: { - name: 'useScriptGoogleSignIn', - from: await resolve('./runtime/registry/google-sign-in'), - }, - }, - { - registryKey: 'googleTagManager', + }), + def('googleTagManager', { + schema: GoogleTagManagerOptions, label: 'Google Tag Manager', - capabilities: CAP_BUNDLE, // reverseProxyIntercept fails: GTM dynamically loads scripts at runtime - defaultCapability: DEF_BUNDLE, + capabilities: CAP_BUNDLE, category: 'tag-manager', - import: { - name: 'useScriptGoogleTagManager', - from: await resolve('./runtime/registry/google-tag-manager'), - }, - logo: LOGOS.googleTagManager, scriptBundling(options) { - if (!options?.id) { + if (!options?.id) return false - } return withQuery('https://www.googletagmanager.com/gtm.js', { id: options.id, l: options.l, @@ -605,60 +498,41 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption gtm_auth_referrer_policy: options.authReferrerPolicy, }) }, - }, - { - registryKey: 'googleAnalytics', + }), + def('googleAnalytics', { + schema: GoogleAnalyticsOptions, label: 'Google Analytics', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['www.google-analytics.com', 'analytics.google.com', 'stats.g.doubleclick.net', 'pagead2.googlesyndication.com', 'www.googleadservices.com', 'googleads.g.doubleclick.net'], privacy: PRIVACY_HEATMAP, category: 'analytics', - import: { - name: 'useScriptGoogleAnalytics', - from: await resolve('./runtime/registry/google-analytics'), - }, - logo: LOGOS.googleAnalytics, scriptBundling(options) { - if (!options?.id) { + if (!options?.id) return false - } return withQuery('https://www.googletagmanager.com/gtag/js', { id: options?.id, l: options?.l }) }, - }, - { - registryKey: 'umamiAnalytics', + }), + def('umamiAnalytics', { + schema: UmamiAnalyticsOptions, label: 'Umami Analytics', capabilities: CAP_FULL_PT, - defaultCapability: DEF_FULL, domains: ['cloud.umami.is', 'api-gateway.umami.dev'], - privacy: PRIVACY_NONE, + privacy: PRIVACY_IP_ONLY, autoInject: { configField: 'hostUrl', computeValue: proxyPrefix => `${proxyPrefix}/cloud.umami.is` }, scriptBundling: () => 'https://cloud.umami.is/script.js', category: 'analytics', - logo: LOGOS.umamiAnalytics, - import: { - name: 'useScriptUmamiAnalytics', - from: await resolve('./runtime/registry/umami-analytics'), - }, - }, - { - registryKey: 'gravatar', + }), + def('gravatar', { + schema: GravatarOptions, label: 'Gravatar', src: 'https://secure.gravatar.com/js/gprofiles.js', capabilities: CAP_FULL, - defaultCapability: DEF_FULL, domains: ['secure.gravatar.com', 'gravatar.com'], privacy: PRIVACY_IP_ONLY, category: 'utility', - logo: LOGOS.gravatar, - import: { - name: 'useScriptGravatar', - from: await resolve('./runtime/registry/gravatar'), - }, serverHandlers: [ - { route: '/_scripts/proxy/gravatar', handler: await resolve('./runtime/server/gravatar-proxy') }, + { route: '/_scripts/proxy/gravatar', handler: './runtime/server/gravatar-proxy' }, ], - }, - ] + }), + ]) } diff --git a/src/runtime/registry/intercom.ts b/src/runtime/registry/intercom.ts index dd0a7c5f..36e7a352 100644 --- a/src/runtime/registry/intercom.ts +++ b/src/runtime/registry/intercom.ts @@ -1,5 +1,5 @@ -import type { InferInput } from '#nuxt-scripts-validator' import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { InferInput } from 'valibot' import { joinURL } from 'ufo' import { useRegistryScript } from '../utils' import { IntercomOptions } from './schemas' diff --git a/src/runtime/registry/plausible-analytics.ts b/src/runtime/registry/plausible-analytics.ts index 68566da6..6d3629c7 100644 --- a/src/runtime/registry/plausible-analytics.ts +++ b/src/runtime/registry/plausible-analytics.ts @@ -1,6 +1,6 @@ import type { RegistryScriptInput } from '#nuxt-scripts/types' import type { UseScriptInput } from '@unhead/vue' -import { any, array, boolean, literal, object, optional, record, string, union } from '#nuxt-scripts-validator' +import { any, array, boolean, literal, object, optional, record, string, union } from 'valibot' import { logger } from '../logger' import { useRegistryScript } from '../utils' diff --git a/src/runtime/registry/schemas.ts b/src/runtime/registry/schemas.ts index 6dabb681..48387b25 100644 --- a/src/runtime/registry/schemas.ts +++ b/src/runtime/registry/schemas.ts @@ -1,4 +1,4 @@ -import { any, array, boolean, custom, literal, minLength, number, object, optional, pipe, record, string, union } from '#nuxt-scripts-validator' +import { any, array, boolean, custom, literal, minLength, number, object, optional, pipe, record, string, union } from 'valibot' export const BlueskyEmbedOptions = object({ /** diff --git a/src/runtime/registry/snapchat-pixel.ts b/src/runtime/registry/snapchat-pixel.ts index 17f32aac..b15e7000 100644 --- a/src/runtime/registry/snapchat-pixel.ts +++ b/src/runtime/registry/snapchat-pixel.ts @@ -1,5 +1,5 @@ -import type { InferInput } from '#nuxt-scripts-validator' import type { RegistryScriptInput } from '#nuxt-scripts/types' +import type { InferInput } from 'valibot' import { useRegistryScript } from '../utils' import { InitObjectPropertiesSchema, SnapTrPixelOptions } from './schemas' diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 9e6177c2..25b21619 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -3,7 +3,7 @@ import type { Script, } from '@unhead/vue/types' import type { Import } from 'unimport' -import type { InferInput, ObjectSchema, UnionSchema, ValiError } from 'valibot' +import type { InferInput, ObjectEntries, ObjectSchema, UnionSchema, ValiError } from 'valibot' import type { ComputedRef, Ref } from 'vue' import type { BingUetInput } from './registry/bing-uet' import type { BlueskyEmbedInput } from './registry/bluesky-embed' @@ -42,7 +42,6 @@ import type { VimeoPlayerInput } from './registry/vimeo-player' import type { XEmbedInput } from './registry/x-embed' import type { XPixelInput } from './registry/x-pixel' import type { YouTubePlayerInput } from './registry/youtube-player' -import { object } from '#nuxt-scripts-validator' export type WarmupStrategy = false | 'preload' | 'preconnect' | 'dns-prefetch' @@ -76,12 +75,12 @@ export type NuxtUseScriptOptions = {}> = */ bundle?: boolean | 'force' /** - * Control reverse proxy interception for this script. + * Control proxying for this script. * When `false`, collection requests go directly to the third-party server. * When `true`, collection requests are proxied through `/_scripts/p/`. - * Defaults to the script's `defaultCapability.reverseProxyIntercept` from the registry. + * Defaults to the script's `defaultCapability.proxy` from the registry. */ - reverseProxyIntercept?: boolean + proxy?: boolean /** * Load the script in a web worker using Partytown. * When enabled, adds `type="text/partytown"` to the script tag. @@ -226,7 +225,9 @@ export type BuiltInRegistryScriptKey */ export type RegistryScriptKey = Exclude -export type NuxtConfigScriptRegistryEntry = true | false | 'mock' | T | [T, NuxtUseScriptOptionsSerializable] +type RegistryConfigInput = [T] extends [true] ? Record : T + +export type NuxtConfigScriptRegistryEntry = true | false | 'mock' | (RegistryConfigInput & { trigger?: NuxtUseScriptOptionsSerializable['trigger'], proxy?: boolean, bundle?: boolean, partytown?: boolean, scriptOptions?: Omit }) | [RegistryConfigInput, NuxtUseScriptOptionsSerializable] export type NuxtConfigScriptRegistry = Partial<{ [key in T]: NuxtConfigScriptRegistryEntry }> & Record> @@ -235,9 +236,7 @@ export type UseFunctionType = T extends { use: infer V } ? V extends (...args: any) => any ? ReturnType : U : U -const _emptyOptions = object({}) - -export type EmptyOptionsSchema = typeof _emptyOptions +export type EmptyOptionsSchema = ObjectSchema type ScriptInput = Script @@ -275,7 +274,7 @@ export interface ScriptCapabilities { * When combined with `bundle`: AST URL rewriting + runtime intercept. * Without `bundle` (npm mode): autoInject sets SDK endpoint to proxy URL. */ - reverseProxyIntercept?: boolean + proxy?: boolean /** Script can run in a web worker via Partytown. */ partytown?: boolean } @@ -322,7 +321,7 @@ export interface RegistryScript { domains?: (string | ScriptDomain)[] /** * Privacy controls for proxied requests to this script's domains. - * Only relevant when reverseProxyIntercept capability is active. + * Only relevant when proxy capability is active. */ privacy?: import('../runtime/server/utils/privacy').ProxyPrivacyInput /** @@ -348,6 +347,11 @@ export interface RegistryScript { * Server handlers (routes/middleware) to register when this script is enabled via registry config. */ serverHandlers?: RegistryScriptServerHandler[] + /** + * Valibot schema for the script's input options. + * Used for build-time validation (extracting required fields) and runtime validation in dev mode. + */ + schema?: ObjectSchema } export type ElementScriptTrigger = 'immediate' | 'visible' | string | string[] | false diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts index 1c1c21e0..935efd78 100644 --- a/src/runtime/utils.ts +++ b/src/runtime/utils.ts @@ -9,10 +9,10 @@ import type { } from '#nuxt-scripts/types' import type { UseScriptInput } from '@unhead/vue' import type { GenericSchema, InferInput, ObjectSchema, UnionSchema, ValiError } from 'valibot' -import { parse } from '#nuxt-scripts-validator' import { defu } from 'defu' import { createError, useRuntimeConfig } from 'nuxt/app' import { parseQuery, parseURL, withQuery } from 'ufo' +import { parse } from 'valibot' import { useScript } from './composables/useScript' import { createNpmScriptStub } from './npm-script-stub' @@ -23,7 +23,7 @@ const CLEAN_CALLER_RE = /^\s*at\s+/ export type MaybePromise = Promise | T -function validateScriptInputSchema(key: string, schema: T, options?: InferInput) { +function validateScriptInputSchema(schema: T, options?: InferInput) { if (import.meta.dev) { try { parse(schema, options) @@ -135,11 +135,10 @@ export function useRegistryScript, O = Em } scriptOptions.devtools = defu(scriptOptions.devtools, { registryKey, loadedFrom }) - if (options.schema) { + if (options.schema && 'entries' in options.schema) { const registryMeta: Record = {} - const entries = 'entries' in options.schema ? options.schema.entries as Record : undefined - for (const k in entries) { - if (entries[k]?.type !== 'optional') { + for (const k in options.schema.entries) { + if (options.schema.entries[k]?.type !== 'optional') { registryMeta[k] = String(userOptions[k as any as keyof typeof userOptions]) } } @@ -153,7 +152,7 @@ export function useRegistryScript, O = Em // a manual trigger also means it was disabled by nuxt.config // overriding the src will skip validation if (!userOptions.scriptInput?.src && !scriptOptions.skipValidation && options.schema) { - return validateScriptInputSchema(registryKey, options.schema, userOptions) + return validateScriptInputSchema(options.schema, userOptions) } } } diff --git a/src/runtime/validation/mock.ts b/src/runtime/validation/mock.ts deleted file mode 100644 index bcf36efb..00000000 --- a/src/runtime/validation/mock.ts +++ /dev/null @@ -1,24 +0,0 @@ -const noop = Object.freeze( - Object.assign( - () => { - /** noop */ - }, - { __mock__: true }, - ), -) - -export const parse = noop -export const object = noop -export const array = noop -export const string = noop -export const number = noop -export const boolean = noop -export const optional = noop -export const literal = noop -export const union = noop -export const record = noop -export const any = noop -export const minLength = noop - -export const pipe = noop -export const custom = noop diff --git a/src/runtime/validation/valibot.ts b/src/runtime/validation/valibot.ts deleted file mode 100644 index 196687d6..00000000 --- a/src/runtime/validation/valibot.ts +++ /dev/null @@ -1 +0,0 @@ -export * from 'valibot' diff --git a/src/stats.ts b/src/stats.ts index 956199a3..7f65f9a7 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -551,7 +551,7 @@ export async function getScriptStats(): Promise { const id = entry.registryKey || deriveMetaKey(entry.import?.name, entry.label) as RegistryScriptKey const meta = id && id in scriptMeta ? scriptMeta[id as keyof typeof scriptMeta] : undefined const size = sizes[id || ''] - const proxyConfigKey = !entry.capabilities?.reverseProxyIntercept ? undefined : (entry.proxyConfig || entry.registryKey) + const proxyConfigKey = !entry.capabilities?.proxy ? undefined : (entry.proxyConfig || entry.registryKey) const proxyConfig = proxyConfigKey ? proxyConfigs[proxyConfigKey] : undefined // Determine loading method diff --git a/src/templates.ts b/src/templates.ts index 44870db9..22343215 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -107,7 +107,7 @@ export function templatePlugin(config: Partial, registry: Require if (Array.isArray(config.globals)) { // convert to object config.globals = Object.fromEntries(config.globals.map(i => [hash(i), i])) - logger.warn('The `globals` array option is deprecated, please convert to an object.') + logger.warn('The `globals` array option is deprecated. Convert to an object: `globals: { myScript: \'https://example.com/script.js\' }`') } const imports = [] const inits = [] @@ -117,36 +117,36 @@ export function templatePlugin(config: Partial, registry: Require let needsServiceWorkerImport = false - // Registry entries are pre-normalized to [input, scriptOptions?] tuple form + // Registry entries are pre-normalized to [input, scriptOptions?] tuple form. + // Only generate a global composable call when scriptOptions.trigger is present; + // entries without a trigger are infrastructure only (proxy routes, types, bundling). for (const [k, c] of Object.entries(config.registry || {})) { if (c === false) continue + const [, scriptOptions] = c as [Record, any?] + if (!scriptOptions?.trigger) + continue const importDefinition = registry.find(i => i.import.name.toLowerCase() === `usescript${k.toLowerCase()}`) if (importDefinition) { resolvedRegistryKeys.push(k) imports.unshift(`import { ${importDefinition.import.name} } from '${importDefinition.import.from}'`) - const [input, scriptOptions] = c as [Record, any?] - if (scriptOptions) { - const opts = { ...scriptOptions } - const triggerResolved = resolveTriggerForTemplate(opts.trigger) - if (triggerResolved) { - opts.trigger = '__TRIGGER_PLACEHOLDER__' as any - if (triggerResolved.includes('useScriptTriggerIdleTimeout')) - needsIdleTimeoutImport = true - if (triggerResolved.includes('useScriptTriggerInteraction')) - needsInteractionImport = true - if (triggerResolved.includes('useScriptTriggerServiceWorker')) - needsServiceWorkerImport = true - } - const args = { ...input, scriptOptions: opts } - const argsJson = triggerResolved - ? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) - : JSON.stringify(args) - inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`) - } - else { - inits.push(`const ${k} = ${importDefinition.import.name}(${JSON.stringify(input)})`) + const [input] = c as [Record, any?] + const opts = { ...scriptOptions } + const triggerResolved = resolveTriggerForTemplate(opts.trigger) + if (triggerResolved) { + opts.trigger = '__TRIGGER_PLACEHOLDER__' as any + if (triggerResolved.includes('useScriptTriggerIdleTimeout')) + needsIdleTimeoutImport = true + if (triggerResolved.includes('useScriptTriggerInteraction')) + needsInteractionImport = true + if (triggerResolved.includes('useScriptTriggerServiceWorker')) + needsServiceWorkerImport = true } + const args = { ...input, scriptOptions: opts } + const argsJson = triggerResolved + ? JSON.stringify(args).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved) + : JSON.stringify(args) + inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`) } } for (const [k, c] of Object.entries(config.globals || {})) { diff --git a/test/e2e/base.test.ts b/test/e2e/base.test.ts index 87aa2792..95722af3 100644 --- a/test/e2e/base.test.ts +++ b/test/e2e/base.test.ts @@ -20,6 +20,6 @@ describe('base', async () => { await page.waitForTimeout(500) // get content of #script-src const text = await page.$eval('#script-src', el => el.textContent) - expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/PHzhM8DFXcXVSSJF110cyV3pjg9cp8oWv_f4Dk2ax1w.js"`) + expect(text).toMatchInlineSnapshot(`"/foo/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`) }) }) diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts index ae927003..8dd0a578 100644 --- a/test/e2e/basic.test.ts +++ b/test/e2e/basic.test.ts @@ -178,7 +178,7 @@ describe('basic', () => { await page.waitForTimeout(500) // get content of #script-src const text = await page.$eval('#script-src', el => el.textContent) - expect(text).toMatchInlineSnapshot(`"/_scripts/assets/PHzhM8DFXcXVSSJF110cyV3pjg9cp8oWv_f4Dk2ax1w.js"`) + expect(text).toMatchInlineSnapshot(`"/_scripts/assets/6bEy8slcRmYcRT4E2QbQZ1CMyWw9PpHA7L87BtvSs2U.js"`) }) it('partytown adds type attribute', async () => { const { page } = await createPage('/partytown') diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index d2e610ea..8c2419ba 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -6,10 +6,10 @@ export default defineNuxtConfig({ ], scripts: { registry: { - xEmbed: true, - instagramEmbed: true, - blueskyEmbed: true, - gravatar: true, + xEmbed: {}, + instagramEmbed: {}, + blueskyEmbed: {}, + gravatar: {}, }, }, devtools: { diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 36261a0d..968630fd 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -72,7 +72,7 @@ export default defineNuxtConfig({ posthog: [{ apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W' }, manual], intercom: [{ app_id: 'akg5rmxb' }, manual], crisp: [{ id: 'b1021910-7ace-425a-9ef5-07f49e5ce417' }, manual], - vercelAnalytics: [true, manual], + vercelAnalytics: [{}, manual], }, }, }) diff --git a/test/unit/auto-inject.test.ts b/test/unit/auto-inject.test.ts index 94879af9..b1727687 100644 --- a/test/unit/auto-inject.test.ts +++ b/test/unit/auto-inject.test.ts @@ -93,20 +93,20 @@ describe('autoInject via proxy configs', () => { }) }) - describe('boolean entries', () => { - it('injects into runtimeConfig for posthog: true', async () => { - const registry: any = { posthog: true } + describe('empty object entries (env var driven)', () => { + it('injects into runtimeConfig for posthog: {}', async () => { + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '' } }) await autoInjectAll(registry, rt, '/_proxy') - // After normalization, true becomes [{}] — both input and runtimeConfig get the value + // After normalization, {} becomes [{}] — both input and runtimeConfig get the value expect(registry.posthog[0].apiHost).toBe('/_proxy/us.i.posthog.com') expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/us.i.posthog.com') }) - it('uses EU prefix for posthog: true when runtime region is eu', async () => { - const registry: any = { posthog: true } + it('uses EU prefix for posthog: {} when runtime region is eu', async () => { + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '', region: 'eu' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -114,8 +114,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.posthog.apiHost).toBe('/_proxy/eu.i.posthog.com') }) - it('injects into runtimeConfig for plausibleAnalytics: true', async () => { - const registry: any = { plausibleAnalytics: true } + it('injects into runtimeConfig for plausibleAnalytics: {}', async () => { + const registry: any = { plausibleAnalytics: {} } const rt = makeRuntimeConfig({ plausibleAnalytics: { domain: '' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -123,8 +123,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.plausibleAnalytics.endpoint).toBe('/_proxy/plausible.io/api/event') }) - it('injects into runtimeConfig for umamiAnalytics: true', async () => { - const registry: any = { umamiAnalytics: true } + it('injects into runtimeConfig for umamiAnalytics: {}', async () => { + const registry: any = { umamiAnalytics: {} } const rt = makeRuntimeConfig({ umamiAnalytics: { websiteId: '' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -132,8 +132,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.umamiAnalytics.hostUrl).toBe('/_proxy/cloud.umami.is') }) - it('injects into runtimeConfig for rybbitAnalytics: true', async () => { - const registry: any = { rybbitAnalytics: true } + it('injects into runtimeConfig for rybbitAnalytics: {}', async () => { + const registry: any = { rybbitAnalytics: {} } const rt = makeRuntimeConfig({ rybbitAnalytics: { siteId: '' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -141,8 +141,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.rybbitAnalytics.analyticsHost).toBe('/_proxy/app.rybbit.io/api') }) - it('injects into runtimeConfig for databuddyAnalytics: true', async () => { - const registry: any = { databuddyAnalytics: true } + it('injects into runtimeConfig for databuddyAnalytics: {}', async () => { + const registry: any = { databuddyAnalytics: {} } const rt = makeRuntimeConfig({ databuddyAnalytics: { clientId: '' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -191,9 +191,9 @@ describe('autoInject via proxy configs', () => { }) }) - describe('reverseProxyIntercept opt-out', () => { - it('skips auto-inject when input has reverseProxyIntercept: false', async () => { - const registry: any = { plausibleAnalytics: { domain: 'example.com', reverseProxyIntercept: false } } + describe('proxy opt-out', () => { + it('skips auto-inject when input has proxy: false', async () => { + const registry: any = { plausibleAnalytics: { domain: 'example.com', proxy: false } } const rt = makeRuntimeConfig({ plausibleAnalytics: { domain: 'example.com' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -202,8 +202,8 @@ describe('autoInject via proxy configs', () => { expect(rt.public.scripts.plausibleAnalytics.endpoint).toBeUndefined() }) - it('skips auto-inject when scriptOptions has reverseProxyIntercept: false', async () => { - const registry: any = { posthog: [{ apiKey: 'phc_test' }, { reverseProxyIntercept: false }] } + it('skips auto-inject when scriptOptions has proxy: false', async () => { + const registry: any = { posthog: [{ apiKey: 'phc_test' }, { proxy: false }] } const rt = makeRuntimeConfig({ posthog: { apiKey: 'phc_test' } }) await autoInjectAll(registry, rt, '/_proxy') @@ -215,7 +215,7 @@ describe('autoInject via proxy configs', () => { describe('custom proxyPrefix', () => { it('uses custom prefix in computed values', async () => { - const registry: any = { posthog: true } + const registry: any = { posthog: {} } const rt = makeRuntimeConfig({ posthog: { apiKey: '' } }) await autoInjectAll(registry, rt, '/_analytics') diff --git a/test/unit/first-party.test.ts b/test/unit/first-party.test.ts index 3eef401a..48c1b0e4 100644 --- a/test/unit/first-party.test.ts +++ b/test/unit/first-party.test.ts @@ -76,12 +76,12 @@ describe('first-party mode', () => { } }) - it('every proxy config is referenced by at least one registry script with reverseProxyIntercept', async () => { + it('every proxy config is referenced by at least one registry script with proxy', async () => { const scripts = await getRegistryScripts() const configs = await getProxyConfigs() const usedProxyKeys = new Set() for (const s of scripts) { - if (!s.capabilities?.reverseProxyIntercept) + if (!s.capabilities?.proxy) continue if (s.proxyConfig) usedProxyKeys.add(s.proxyConfig) @@ -169,12 +169,12 @@ describe('first-party mode', () => { }) describe('full chain: capabilities → proxy config → domains', () => { - it('every script with reverseProxyIntercept gets domains via registryKey lookup', async () => { + it('every script with proxy gets domains via registryKey lookup', async () => { const scripts = await getRegistryScripts() const configs = await getProxyConfigs() for (const script of scripts) { - if (!script.capabilities?.reverseProxyIntercept || !script.registryKey) + if (!script.capabilities?.proxy || !script.registryKey) continue const configKey = script.proxyConfig || script.registryKey @@ -195,7 +195,7 @@ describe('first-party mode', () => { const allDomains = new Set() for (const script of scripts) { - if (!script.capabilities?.reverseProxyIntercept || !script.registryKey) + if (!script.capabilities?.proxy || !script.registryKey) continue const configKey = script.proxyConfig || script.registryKey const proxyConfig = configs[configKey] @@ -238,15 +238,15 @@ describe('first-party mode', () => { }) }) - describe('scripts that need fingerprinting have no reverseProxyIntercept', () => { - it('stripe, paypal, googleRecaptcha, googleSignIn have no reverseProxyIntercept capability', async () => { + describe('scripts that need fingerprinting have no proxy', () => { + it('stripe, paypal, googleRecaptcha, googleSignIn have no proxy capability', async () => { const scripts = await getRegistryScripts() const fingerprintScripts = ['stripe', 'paypal', 'googleRecaptcha', 'googleSignIn'] for (const key of fingerprintScripts) { const script = scripts.find(s => s.registryKey === key) expect(script, `${key} should exist in registry`).toBeDefined() - expect(script!.capabilities?.reverseProxyIntercept, `${key} should not have reverseProxyIntercept`).toBeFalsy() + expect(script!.capabilities?.proxy, `${key} should not have proxy`).toBeFalsy() } }) diff --git a/test/unit/normalize.test.ts b/test/unit/normalize.test.ts index 32181ace..8770ae43 100644 --- a/test/unit/normalize.test.ts +++ b/test/unit/normalize.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest' import { normalizeRegistryConfig } from '../../src/normalize' describe('normalizeRegistryConfig', () => { - it('normalizes true to [{}]', () => { + it('normalizes true to [{}, { trigger: "onNuxtReady" }]', () => { const registry: Record = { plausible: true } normalizeRegistryConfig(registry) - expect(registry.plausible).toEqual([{}]) + expect(registry.plausible).toEqual([{}, { trigger: 'onNuxtReady' }]) + }) + + it('throws on "proxy-only" with migration message', () => { + const registry: Record = { ga: 'proxy-only' } + expect(() => normalizeRegistryConfig(registry)).toThrowError(/proxy-only.*no longer supported/) }) it('normalizes "mock" to [{}, { trigger: "manual", skipValidation: true }]', () => { @@ -14,14 +19,50 @@ describe('normalizeRegistryConfig', () => { expect(registry.plausible).toEqual([{}, { trigger: 'manual', skipValidation: true }]) }) - it('wraps plain object in array', () => { + it('wraps empty object in array', () => { + const registry: Record = { plausible: {} } + normalizeRegistryConfig(registry) + expect(registry.plausible).toEqual([{}]) + }) + + it('wraps plain object without hoisted keys in array', () => { const registry: Record = { plausible: { domain: 'mysite.com' } } normalizeRegistryConfig(registry) expect(registry.plausible).toEqual([{ domain: 'mysite.com' }]) }) + it('hoists trigger to scriptOptions', () => { + const registry: Record = { ga: { id: 'G-xxx', trigger: 'onNuxtReady' } } + normalizeRegistryConfig(registry) + expect(registry.ga).toEqual([{ id: 'G-xxx' }, { trigger: 'onNuxtReady' }]) + }) + + it('hoists proxy to scriptOptions', () => { + const registry: Record = { plausible: { domain: 'mysite.com', proxy: false } } + normalizeRegistryConfig(registry) + expect(registry.plausible).toEqual([{ domain: 'mysite.com' }, { proxy: false }]) + }) + + it('hoists bundle and partytown to scriptOptions', () => { + const registry: Record = { ga: { id: 'G-xxx', bundle: false, partytown: true } } + normalizeRegistryConfig(registry) + expect(registry.ga).toEqual([{ id: 'G-xxx' }, { bundle: false, partytown: true }]) + }) + + it('merges hoisted keys with scriptOptions', () => { + const registry: Record = { ga: { id: 'G-xxx', trigger: 'onNuxtReady', scriptOptions: { warmupStrategy: 'preconnect' } } } + normalizeRegistryConfig(registry) + expect(registry.ga).toEqual([{ id: 'G-xxx' }, { trigger: 'onNuxtReady', warmupStrategy: 'preconnect' }]) + }) + + it('top-level flags take precedence over scriptOptions', () => { + const registry: Record = { ga: { id: 'G-xxx', proxy: true, scriptOptions: { proxy: false } } } + normalizeRegistryConfig(registry) + expect(registry.ga).toEqual([{ id: 'G-xxx' }, { proxy: true }]) + }) + it('leaves valid tuple unchanged', () => { - const entry = [{ domain: 'mysite.com' }, { reverseProxyIntercept: false }] + const entry = [{ domain: 'mysite.com' }, { proxy: false }] const registry: Record = { plausible: entry } normalizeRegistryConfig(registry) expect(registry.plausible).toBe(entry) @@ -57,13 +98,11 @@ describe('normalizeRegistryConfig', () => { it('handles multiple entries in one pass', () => { const registry: Record = { - plausible: true, ga: { id: 'G-XXX' }, posthog: 'mock', stripe: false, } normalizeRegistryConfig(registry) - expect(registry.plausible).toEqual([{}]) expect(registry.ga).toEqual([{ id: 'G-XXX' }]) expect(registry.posthog).toEqual([{}, { trigger: 'manual', skipValidation: true }]) expect(registry.stripe).toBeUndefined() diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 22a8c485..95a35ac6 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -429,7 +429,7 @@ describe('proxy configs', () => { it('all configs have valid structure', async () => { const configs = await getProxyConfigs() const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel'] - const passthrough = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'fathomAnalytics', 'vercelAnalytics'] + const ipOnly = ['posthog', 'plausibleAnalytics', 'cloudflareWebAnalytics', 'rybbitAnalytics', 'umamiAnalytics', 'databuddyAnalytics', 'fathomAnalytics', 'vercelAnalytics', 'matomoAnalytics', 'carbonAds', 'intercom', 'lemonSqueezy', 'vimeoPlayer', 'youtubePlayer', 'gravatar'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have domains`).toHaveProperty('domains') expect(Array.isArray(config.domains), `${key}.domains should be an array`).toBe(true) @@ -452,10 +452,13 @@ describe('proxy configs', () => { hardware: true, }) } - if (passthrough.includes(key)) { - for (const flag of Object.values(config.privacy)) { - expect(flag, `${key} privacy flags should be false`).toBe(false) - } + if (ipOnly.includes(key)) { + expect(config.privacy.ip, `${key} should anonymize IP`).toBe(true) + expect(config.privacy.userAgent, `${key} should not anonymize userAgent`).toBe(false) + expect(config.privacy.language, `${key} should not anonymize language`).toBe(false) + expect(config.privacy.screen, `${key} should not anonymize screen`).toBe(false) + expect(config.privacy.timezone, `${key} should not anonymize timezone`).toBe(false) + expect(config.privacy.hardware, `${key} should not anonymize hardware`).toBe(false) } } }) diff --git a/test/unit/setup.test.ts b/test/unit/setup.test.ts index 39a465dc..3a52465d 100644 --- a/test/unit/setup.test.ts +++ b/test/unit/setup.test.ts @@ -52,15 +52,15 @@ describe('applyAutoInject', () => { expect((registry as any).posthog[0].apiHost).toBe('https://custom.host') }) - it('skips when input has reverseProxyIntercept: false', () => { - const registry = makeRegistry('posthog', [{ apiKey: 'pk_123', reverseProxyIntercept: false }]) + it('skips when input has proxy: false', () => { + const registry = makeRegistry('posthog', [{ apiKey: 'pk_123', proxy: false }]) const runtimeConfig = makeRuntimeConfig() applyAutoInject(registry, runtimeConfig, '/_proxy', 'posthog', posthogAutoInject) expect((registry as any).posthog[0].apiHost).toBeUndefined() }) - it('skips when scriptOptions has reverseProxyIntercept: false', () => { - const registry = makeRegistry('posthog', [{ apiKey: 'pk_123' }, { reverseProxyIntercept: false }]) + it('skips when scriptOptions has proxy: false', () => { + const registry = makeRegistry('posthog', [{ apiKey: 'pk_123' }, { proxy: false }]) const runtimeConfig = makeRuntimeConfig() applyAutoInject(registry, runtimeConfig, '/_proxy', 'posthog', posthogAutoInject) expect((registry as any).posthog[0].apiHost).toBeUndefined() diff --git a/test/unit/templates.test.ts b/test/unit/templates.test.ts index 8eb275d7..88107571 100644 --- a/test/unit/templates.test.ts +++ b/test/unit/templates.test.ts @@ -110,7 +110,7 @@ describe('template plugin file', () => { `) }) // registry - it('registry object', async () => { + it('registry object without trigger (infrastructure only, no composable call)', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -125,9 +125,27 @@ describe('template plugin file', () => { }, }, ]) - expect(res).toContain('useScriptStripe({"id":"test"})') + expect(res).not.toContain('useScriptStripe') }) - it('registry array', async () => { + it('registry object with trigger (auto-loads globally)', async () => { + const res = templatePluginNormalized({ + globals: {}, + registry: { + stripe: { + id: 'test', + trigger: 'onNuxtReady', + }, + }, + }, [ + { + import: { + name: 'useScriptStripe', + }, + }, + ]) + expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})') + }) + it('registry array with trigger', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -150,7 +168,7 @@ describe('template plugin file', () => { expect(res).toContain('useScriptStripe({"id":"test","scriptOptions":{"trigger":"onNuxtReady"}})') }) - it('registry with partytown option', async () => { + it('registry with partytown but no trigger (no composable call)', async () => { const res = templatePluginNormalized({ globals: {}, registry: { @@ -166,7 +184,26 @@ describe('template plugin file', () => { }, }, ]) - expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true}})') + expect(res).not.toContain('useScriptGoogleAnalytics') + }) + + it('registry with partytown and trigger', async () => { + const res = templatePluginNormalized({ + globals: {}, + registry: { + googleAnalytics: [ + { id: 'G-XXXXX' }, + { partytown: true, trigger: 'onNuxtReady' }, + ], + }, + }, [ + { + import: { + name: 'useScriptGoogleAnalytics', + }, + }, + ]) + expect(res).toContain('useScriptGoogleAnalytics({"id":"G-XXXXX","scriptOptions":{"partytown":true,"trigger":"onNuxtReady"}})') }) // Test idleTimeout trigger in globals diff --git a/test/unit/third-party-proxy-replacements.test.ts b/test/unit/third-party-proxy-replacements.test.ts index cbc98fc3..ac6b2f09 100644 --- a/test/unit/third-party-proxy-replacements.test.ts +++ b/test/unit/third-party-proxy-replacements.test.ts @@ -37,7 +37,7 @@ const testCases: ScriptTestCase[] = [ expectedPatterns: ['www.google.com/g/collect', 'googletagmanager.com'], forbiddenAfterRewrite: ['www.google.com/g/collect'], }, - // GTM removed: reverseProxyIntercept not supported (dynamic script loading) + // GTM removed: proxy not supported (dynamic script loading) { name: 'Meta Pixel (fbevents.js)', url: 'https://connect.facebook.net/en_US/fbevents.js', @@ -66,7 +66,7 @@ const testCases: ScriptTestCase[] = [ expectedPatterns: ['hotjar'], forbiddenAfterRewrite: ['static.hotjar.com', 'vars.hotjar.com'], }, - // Segment removed: reverseProxyIntercept not supported (dynamic API URL construction) + // Segment removed: proxy not supported (dynamic API URL construction) ] describe('third-party script proxy replacements', () => { diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index f21b6102..a61ecab1 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -10,10 +10,6 @@ vi.mock('../../src/runtime/composables/useScript', () => ({ useScript: vi.fn((input, options) => ({ input, options })), })) -vi.mock('#nuxt-scripts-validator', () => ({ - parse: vi.fn(), -})) - describe('useRegistryScript scriptOptions', () => { it('should not mutate user-provided scriptOptions', () => { const mockOptionsFunction = vi.fn((_opts, _ctx) => ({