Skip to content

Commit 7ef19de

Browse files
harlan-zwclaude
andcommitted
feat: add first-party mode for third-party script routing
- Add `scripts.firstParty` config option to route scripts through your domain - Download scripts at build time and rewrite collection URLs to local paths - Inject Nitro route rules to proxy requests to original endpoints - Privacy benefits: hides user IPs, eliminates third-party cookies - Add `proxy` field to RegistryScript type to mark supported scripts - Deprecate `bundle` option in favor of unified `firstParty` config - Add comprehensive unit tests and documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fb3d15e commit 7ef19de

File tree

9 files changed

+883
-11
lines changed

9 files changed

+883
-11
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
---
2+
title: First-Party Mode
3+
description: Route third-party script traffic through your domain for improved privacy and reliability.
4+
---
5+
6+
## Background
7+
8+
When third-party scripts load directly from external servers, they expose your users' data:
9+
10+
- **IP address exposure** - Every request reveals your users' IP addresses to third parties
11+
- **Third-party cookies** - External scripts can set cookies for cross-site tracking
12+
- **Ad blocker interference** - Privacy tools block requests to known tracking domains
13+
- **Connection overhead** - Extra DNS lookups and TLS handshakes slow page loads
14+
15+
### How First-Party Mode Helps
16+
17+
First-party mode routes all script traffic through your domain:
18+
19+
- **User IPs stay private** - Third parties see your server's IP, not your users'
20+
- **No third-party cookies** - Requests are same-origin, eliminating cross-site tracking
21+
- **Works with ad blockers** - Requests appear first-party
22+
- **Faster loads** - No extra DNS lookups for external domains
23+
24+
## How it Works
25+
26+
When first-party mode is enabled:
27+
28+
1. **Build time**: Scripts are downloaded and URLs are rewritten to local paths (e.g., `https://www.google-analytics.com/g/collect``/_scripts/c/ga/g/collect`)
29+
2. **Runtime**: Nitro route rules proxy requests from local paths back to original endpoints
30+
31+
```
32+
User Browser → Your Server (/_scripts/c/ga/...) → Google Analytics
33+
```
34+
35+
Your users never connect directly to third-party servers.
36+
37+
## Usage
38+
39+
### Enable Globally
40+
41+
Enable first-party mode for all supported scripts:
42+
43+
```ts [nuxt.config.ts]
44+
export default defineNuxtConfig({
45+
scripts: {
46+
firstParty: true,
47+
registry: {
48+
googleAnalytics: { id: 'G-XXXXXX' },
49+
metaPixel: { id: '123456' },
50+
}
51+
}
52+
})
53+
```
54+
55+
### Custom Paths
56+
57+
Customize the proxy endpoint paths:
58+
59+
```ts [nuxt.config.ts]
60+
export default defineNuxtConfig({
61+
scripts: {
62+
firstParty: {
63+
collectPrefix: '/_analytics', // Default: /_scripts/c
64+
}
65+
}
66+
})
67+
```
68+
69+
### Opt-out Per Script
70+
71+
Disable first-party routing for a specific script:
72+
73+
```ts
74+
useScriptGoogleAnalytics({
75+
id: 'G-XXXXXX',
76+
scriptOptions: {
77+
firstParty: false, // Load directly from Google
78+
}
79+
})
80+
```
81+
82+
## Supported Scripts
83+
84+
First-party mode supports the following scripts:
85+
86+
| Script | Endpoints Routed |
87+
|--------|------------------|
88+
| Google Analytics | `www.google.com/g/collect`, `www.google-analytics.com` |
89+
| Google Tag Manager | `www.googletagmanager.com` |
90+
| Meta Pixel | `connect.facebook.net`, `www.facebook.com/tr` |
91+
| TikTok Pixel | `analytics.tiktok.com` |
92+
| Segment | `api.segment.io`, `cdn.segment.com` |
93+
| Microsoft Clarity | `www.clarity.ms` |
94+
| Hotjar | `static.hotjar.com`, `vars.hotjar.com` |
95+
| X/Twitter Pixel | `analytics.twitter.com`, `t.co` |
96+
| Snapchat Pixel | `tr.snapchat.com` |
97+
| Reddit Pixel | `alb.reddit.com` |
98+
99+
## Requirements
100+
101+
First-party mode requires a **server runtime**. It won't work with fully static hosting (e.g., `nuxt generate` to GitHub Pages) because the proxy endpoints need a server to forward requests.
102+
103+
For static deployments, you can still enable first-party mode - scripts will be bundled with rewritten URLs, but you'll need to configure your hosting platform's rewrite rules manually.
104+
105+
### Static Hosting Rewrites
106+
107+
If deploying statically, configure your platform to proxy these paths:
108+
109+
```
110+
/_scripts/c/ga/* → https://www.google.com/*
111+
/_scripts/c/gtm/* → https://www.googletagmanager.com/*
112+
/_scripts/c/meta/* → https://connect.facebook.net/*
113+
```
114+
115+
## First-Party vs Bundle
116+
117+
First-party mode supersedes the `bundle` option:
118+
119+
| Feature | `bundle: true` | `firstParty: true` |
120+
|---------|---------------|-------------------|
121+
| Downloads script at build |||
122+
| Serves from your domain |||
123+
| Rewrites collection URLs |||
124+
| Proxies API requests |||
125+
| Hides user IPs |||
126+
| Blocks third-party cookies |||
127+
128+
The `bundle` option only self-hosts the script file. First-party mode also rewrites and proxies all collection/tracking endpoints, providing complete first-party routing.
129+
130+
::callout{type="warning"}
131+
The `bundle` option is deprecated. Use `firstParty: true` for new projects.
132+
::
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ title: Bundling Remote Scripts
33
description: Optimize third-party scripts by bundling them with your app.
44
---
55

6+
::callout{type="warning"}
7+
The `bundle` option is deprecated in favor of [First-Party Mode](/docs/guides/first-party), which provides the same benefits plus routed collection endpoints for improved privacy. Use `firstParty: true` for new projects.
8+
::
9+
610
## Background
711

812
When you use scripts from other sites on your website, you rely on another server to load these scripts. This can slow down your site and raise concerns about safety and privacy.

src/module.ts

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,31 @@ import type {
2626
} from './runtime/types'
2727
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
2828
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
29+
import { getAllProxyConfigs, type ProxyConfig } from './proxy-configs'
30+
31+
export interface FirstPartyOptions {
32+
/**
33+
* Path prefix for serving bundled scripts.
34+
* @default '/_scripts'
35+
*/
36+
prefix?: string
37+
/**
38+
* Path prefix for collection proxy endpoints.
39+
* @default '/_scripts/c'
40+
*/
41+
collectPrefix?: string
42+
}
2943

3044
export interface ModuleOptions {
45+
/**
46+
* Route third-party scripts through your domain for improved privacy.
47+
* When enabled, scripts are downloaded at build time and served from your domain.
48+
* Collection endpoints (analytics, pixels) are also routed through your server,
49+
* keeping user IPs private and eliminating third-party cookies.
50+
*
51+
* @default false
52+
*/
53+
firstParty?: boolean | FirstPartyOptions
3154
/**
3255
* The registry of supported third-party scripts. Loads the scripts in globally using the default script options.
3356
*/
@@ -147,6 +170,26 @@ export default defineNuxtModule<ModuleOptions>({
147170
)
148171
}
149172

173+
// Handle deprecation of bundle option - migrate to firstParty
174+
if (config.defaultScriptOptions?.bundle !== undefined) {
175+
logger.warn(
176+
'`scripts.defaultScriptOptions.bundle` is deprecated. '
177+
+ 'Use `scripts.firstParty: true` instead.',
178+
)
179+
// Migrate: treat bundle as firstParty
180+
if (!config.firstParty && config.defaultScriptOptions.bundle) {
181+
config.firstParty = true
182+
}
183+
}
184+
185+
// Resolve first-party configuration
186+
const firstPartyEnabled = !!config.firstParty
187+
const firstPartyPrefix = typeof config.firstParty === 'object' ? config.firstParty.prefix : undefined
188+
const firstPartyCollectPrefix = typeof config.firstParty === 'object'
189+
? config.firstParty.collectPrefix || '/_scripts/c'
190+
: '/_scripts/c'
191+
const assetsPrefix = firstPartyPrefix || config.assets?.prefix || '/_scripts'
192+
150193
const composables = [
151194
'useScript',
152195
'useScriptEventPage',
@@ -214,6 +257,47 @@ export default defineNuxtModule<ModuleOptions>({
214257
}
215258
const { renderedScript } = setupPublicAssetStrategy(config.assets)
216259

260+
// Inject proxy route rules if first-party mode is enabled
261+
if (firstPartyEnabled) {
262+
const proxyConfigs = getAllProxyConfigs(firstPartyCollectPrefix)
263+
const registryKeys = Object.keys(config.registry || {})
264+
265+
// Collect routes for all configured registry scripts that support proxying
266+
const neededRoutes: Record<string, { proxy: string }> = {}
267+
for (const key of registryKeys) {
268+
// Find the registry script definition
269+
const script = registryScriptsWithImport.find(s => s.import.name === `useScript${key.charAt(0).toUpperCase() + key.slice(1)}`)
270+
// Use script's proxy field if defined, otherwise fall back to registry key
271+
// If proxy is explicitly false, skip this script entirely
272+
const proxyKey = script?.proxy !== false ? (script?.proxy || key) : undefined
273+
if (proxyKey) {
274+
const proxyConfig = proxyConfigs[proxyKey]
275+
if (proxyConfig?.routes) {
276+
Object.assign(neededRoutes, proxyConfig.routes)
277+
}
278+
}
279+
}
280+
281+
// Inject route rules
282+
if (Object.keys(neededRoutes).length) {
283+
nuxt.options.routeRules = {
284+
...nuxt.options.routeRules,
285+
...neededRoutes,
286+
}
287+
}
288+
289+
// Warn for static presets
290+
const preset = nuxt.options.nitro?.preset || process.env.NITRO_PRESET || ''
291+
const staticPresets = ['static', 'github-pages', 'cloudflare-pages-static']
292+
if (staticPresets.includes(preset)) {
293+
logger.warn(
294+
'Proxy collection endpoints require a server runtime. '
295+
+ 'Scripts will be bundled but collection requests will not be proxied. '
296+
+ 'See https://scripts.nuxt.com/docs/guides/proxy for manual platform rewrite configuration.',
297+
)
298+
}
299+
}
300+
217301
const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()
218302

219303
addBuildPlugin(NuxtScriptsCheckScripts(), {
@@ -222,12 +306,14 @@ export default defineNuxtModule<ModuleOptions>({
222306
addBuildPlugin(NuxtScriptBundleTransformer({
223307
scripts: registryScriptsWithImport,
224308
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
225-
defaultBundle: config.defaultScriptOptions?.bundle,
309+
defaultBundle: firstPartyEnabled || config.defaultScriptOptions?.bundle,
310+
firstPartyEnabled,
311+
firstPartyCollectPrefix,
226312
moduleDetected(module) {
227313
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))
228314
moduleInstallPromises.set(module, () => installNuxtModule(module))
229315
},
230-
assetsBaseURL: config.assets?.prefix,
316+
assetsBaseURL: assetsPrefix,
231317
fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail,
232318
fetchOptions: config.assets?.fetchOptions,
233319
cacheMaxAge: config.assets?.cacheMaxAge,

src/plugins/transform.ts

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { FetchOptions } from 'ofetch'
1515
import { $fetch } from 'ofetch'
1616
import { logger } from '../logger'
1717
import { bundleStorage } from '../assets'
18+
import { getProxyConfig, rewriteScriptUrls, type ProxyRewrite } from '../proxy-configs'
1819
import { isJS, isVue } from './util'
1920
import type { RegistryScript } from '#nuxt-scripts/types'
2021

@@ -39,6 +40,14 @@ export interface AssetBundlerTransformerOptions {
3940
* Used to provide default options to script bundling functions when no arguments are provided
4041
*/
4142
registryConfig?: Record<string, any>
43+
/**
44+
* Whether first-party mode is enabled
45+
*/
46+
firstPartyEnabled?: boolean
47+
/**
48+
* Path prefix for collection proxy endpoints
49+
*/
50+
firstPartyCollectPrefix?: string
4251
fallbackOnSrcOnBundleFail?: boolean
4352
fetchOptions?: FetchOptions
4453
cacheMaxAge?: number
@@ -74,8 +83,9 @@ async function downloadScript(opts: {
7483
url: string
7584
filename?: string
7685
forceDownload?: boolean
86+
proxyRewrites?: ProxyRewrite[]
7787
}, renderedScript: NonNullable<AssetBundlerTransformerOptions['renderedScript']>, fetchOptions?: FetchOptions, cacheMaxAge?: number) {
78-
const { src, url, filename, forceDownload } = opts
88+
const { src, url, filename, forceDownload, proxyRewrites } = opts
7989
if (src === url || !filename) {
8090
return
8191
}
@@ -84,7 +94,8 @@ async function downloadScript(opts: {
8494
let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content
8595
if (!res) {
8696
// Use storage to cache the font data between builds
87-
const cacheKey = `bundle:${filename}`
97+
// Include proxy in cache key to differentiate proxied vs non-proxied versions
98+
const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename}` : `bundle:${filename}`
8899
const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
89100

90101
if (shouldUseCache) {
@@ -111,7 +122,15 @@ async function downloadScript(opts: {
111122
return Buffer.from(r._data || await r.arrayBuffer())
112123
})
113124

114-
await storage.setItemRaw(`bundle:${filename}`, res)
125+
// Apply URL rewrites for proxy mode
126+
if (proxyRewrites?.length && res) {
127+
const content = res.toString('utf-8')
128+
const rewritten = rewriteScriptUrls(content, proxyRewrites)
129+
res = Buffer.from(rewritten, 'utf-8')
130+
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
131+
}
132+
133+
await storage.setItemRaw(cacheKey, res)
115134
// Save metadata with timestamp for cache expiration
116135
await storage.setItem(`bundle-meta:${filename}`, {
117136
timestamp: Date.now(),
@@ -195,6 +214,12 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
195214
const node = _node as SimpleCallExpression
196215
let scriptSrcNode: Literal & { start: number, end: number } | undefined
197216
let src: false | string | undefined
217+
// Compute registryKey for proxy config lookup
218+
let registryKey: string | undefined
219+
if (fnName !== 'useScript') {
220+
const baseName = fnName.replace(/^useScript/, '')
221+
registryKey = baseName.length > 0 ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : undefined
222+
}
198223
if (fnName === 'useScript') {
199224
// do easy case first where first argument is a literal
200225
if (node.arguments[0]?.type === 'Literal') {
@@ -219,12 +244,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
219244
return
220245

221246
// integration case
222-
// Get registry key from function name (e.g., useScriptGoogleTagManager -> googleTagManager)
223-
const baseName = fnName.replace(/^useScript/, '')
224-
const registryKey = baseName.length > 0 ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : ''
225-
226247
// Get registry config for this script
227-
const registryConfig = options.registryConfig?.[registryKey] || {}
248+
const registryConfig = options.registryConfig?.[registryKey || ''] || {}
228249

229250
const fnArg0 = {}
230251

@@ -331,11 +352,24 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
331352
canBundle = bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true'
332353
forceDownload = bundleValue === 'force'
333354
}
355+
// Check for per-script first-party opt-out (firstParty: false)
356+
// @ts-expect-error untyped
357+
const firstPartyOption = scriptOptions?.value.properties?.find((prop) => {
358+
return prop.type === 'Property' && prop.key?.name === 'firstParty' && prop.value.type === 'Literal'
359+
})
360+
const firstPartyOptOut = firstPartyOption?.value.value === false
334361
if (canBundle) {
335362
const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL)
336363
let url = _url
364+
// Get proxy rewrites if first-party is enabled, not opted out, and script supports it
365+
// Use script's proxy field if defined, otherwise fall back to registry key
366+
const script = options.scripts.find(s => s.import.name === fnName)
367+
const proxyConfigKey = script?.proxy !== false ? (script?.proxy || registryKey) : undefined
368+
const proxyRewrites = options.firstPartyEnabled && !firstPartyOptOut && proxyConfigKey && options.firstPartyCollectPrefix
369+
? getProxyConfig(proxyConfigKey, options.firstPartyCollectPrefix)?.rewrite
370+
: undefined
337371
try {
338-
await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge)
372+
await downloadScript({ src, url, filename, forceDownload, proxyRewrites }, renderedScript, options.fetchOptions, options.cacheMaxAge)
339373
}
340374
catch (e: any) {
341375
if (options.fallbackOnSrcOnBundleFail) {

0 commit comments

Comments
 (0)