Skip to content

Latest commit

 

History

History
240 lines (184 loc) · 14.9 KB

FIRST_PARTY.md

File metadata and controls

240 lines (184 loc) · 14.9 KB

First-Party Mode — Architecture Reference

Architecture

First-party routing uses bundling or SDK endpoint configuration.

Path A: Bundle + Rewrite + Intercept (most scripts)

  1. Build: Downloads script, rewrites hardcoded URLs via AST using domain-to-proxy mappings derived from proxy-configs.ts domains[], applies SDK-specific postProcess patches
  2. Client: Intercept plugin wraps fetch/sendBeacon/XHR/Image.src via __nuxtScripts — any non-same-origin URL is automatically proxied through /_scripts/p/<host><path>
  3. Server: Nitro handler at /_scripts/p/** extracts the domain from the path, reconstructs the upstream URL, and proxies with privacy transforms

Path B: Config Injection + Proxy

  1. Build: autoInject on the PostHog proxy config sets apiHost → /_scripts/p/us.i.posthog.com
  2. Client: SDK natively uses the injected endpoint — no interception needed
  3. Server: Same Nitro proxy handler

PostHog uses npm mode, with no loader to download or rewrite. Google Analytics keeps its visitor-specific loader remote. The module provides a collection endpoint for its initial gtag configuration through transport_url. Per-script proxy: false and static output disable that endpoint.

TikTok Pixel keeps its loader and collection requests direct to preserve visitor IDs and vendor cookies. It has no collection proxy or proxy anonymization. Google Tag Manager also keeps its visitor-specific loader remote and has no collection proxy.

How runtime interception works

The AST rewriter transforms API calls in downloaded third-party scripts:

  • fetch(url) → __nuxtScripts.fetch(url)
  • navigator.sendBeacon(url) → __nuxtScripts.sendBeacon(url)
  • new XMLHttpRequest → new __nuxtScripts.XMLHttpRequest
  • new Image → new __nuxtScripts.Image

The intercept plugin defines __nuxtScripts with a simple proxyUrl function:

function proxyUrl(url) {
  const parsed = new URL(url, location.origin)
  if (parsed.origin !== location.origin)
    return `${location.origin + proxyPrefix}/${parsed.host}${parsed.pathname}${parsed.search}`
  return url
}

No domain allowlist or rule matching needed. Only AST-rewritten third-party scripts call __nuxtScripts, so any non-same-origin URL is safe to proxy.

The server handler extracts the domain from the path (e.g. /_scripts/p/www.google-analytics.com/g/collect → https://www.google-analytics.com/g/collect) and looks up per-domain privacy config.

Auto-inject (complementary to Path A)

Some Path A scripts also define autoInject on their proxy config to set SDK endpoint config:

  • Plausible: endpoint → /_scripts/p/plausible.io/api/event
  • Umami: hostUrl → /_scripts/p/cloud.umami.is
  • Rybbit: analyticsHost → /_scripts/p/app.rybbit.io/api
  • Databuddy: apiUrl → /_scripts/p/basket.databuddy.cc

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:

  • Rybbit: SDK derives API host from document.currentScript.src.split("/script.js")[0] — breaks when bundled to /_scripts/assets/<hash>.js. Regex replaces the split expression with the proxy path.
  • Fathom: SDK checks src.indexOf("cdn.usefathom.com") < 0 to detect self-hosted mode and overrides the tracker URL. Regex neutralizes this check.

Google Analytics uses its configured collection endpoint instead of AST rewriting or native network interception.

Path aliases (proxy.alias)

By default proxy paths embed the verbatim third-party hostname (/_scripts/p/us.i.posthog.com/e/), which leaks self-hosted/internal domains and is trivially classified by ad-blockers. scripts.proxy.alias replaces the hostname segment with an alias:

  • true — auto-generate a short deterministic hash per domain (sha256(domain).slice(0,8))
  • Record<domain, alias> — explicit aliases; unlisted domains stay verbatim

The pure logic lives in proxy-alias.ts (aliasForDomain, buildDomainAliasMap, invertAliasMap, aliasProxyValue). The module builds a domain → alias map from every proxied domain (those in domainPrivacy) and threads it through every point that emits a proxy path:

  • Build-time rewrites (transform.ts): to: ${proxyPrefix}/${alias ?? domain}
  • Auto-inject (applyAutoInject): aliasProxyValue rewrites the host segment of the computed endpoint
  • Runtime intercept (intercept.ts): embeds the alias map; proxyUrl maps parsed.host → alias
  • Partytown (generatePartytownResolveUrl): embeds the alias map; worker requests map url.host → alias
  • Server handler (proxy-handler.ts): the inverted aliasToDomain map resolves the alias segment back to the real domain before allowlist matching and forwarding (verbatim hostnames still resolve, so aliasing is non-breaking)
  • Devtools (useScript.ts network matcher): aliasToDomain is exposed in devtools config so aliased requests still attribute to their script

Wildcard domains (*) are never aliased — they have no literal path form to rewrite and only exist for runtime allowlist matching.

Key mapping

Proxy config keys match registry keys directly — no indirection layer. A script's registryKey is used to look up its proxy config from proxy-configs.ts.

The only exception is googleAdsense which sets proxy: 'googleAnalytics' to share GA's proxy config.

Per-script opt-out

Scripts can opt out of first-party mode at three levels:

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 (proxy: false in registry config)

Users can opt out per-script in nuxt.config.ts:

scripts.registry.plausibleAnalytics = { domain: 'mysite.com', proxy: false }

Using flat config syntax:

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: { proxy: false })

useScriptPlausibleAnalytics({
  scriptOptions: { proxy: false }
})

This only affects AST rewriting (the transform plugin skips proxy rewrites for the bundled script). It does not undo autoInject config changes, since those run at module setup before transforms. For scripts with autoInject, use the config-level opt-out instead.

Adding a new script

  1. Add a domains[] entry in proxy-configs.ts with the script's domains and a privacy preset
  2. Add a registry entry in registry.ts with a matching registryKey
  3. Done — the transform plugin derives rewrite rules from domains as { from: domain, to: proxyPrefix/domain }

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 proxy capability and set scriptBundling: false in the registry entry.

Privacy presets

Four presets in proxy-configs.ts cover all proxy-enabled scripts:

Preset Flags Used by
PRIVACY_NONE all false (not currently assigned to any script)
PRIVACY_FULL all true Meta, X, Snap, Reddit, LinkedIn
PRIVACY_HEATMAP ip, language, hardware GA, Clarity, Hotjar
PRIVACY_IP_ONLY ip only PostHog, Plausible, Umami, Rybbit, Databuddy, Ahrefs, Fathom, Vercel, Matomo, Carbon Ads, Lemon Squeezy, Intercom, Gravatar, YouTube, Vimeo, Calendly

Note: Cloudflare Web Analytics, TikTok, GTM, Segment, Crisp, Mixpanel, Bing UET, SpeedCurve, Pulse, and Statable have no proxy capability, so no privacy transforms are applied.

Script Support

Config Key Registry Scripts Privacy Mechanism
googleAnalytics googleAnalytics PRIVACY_HEATMAP Path B, remote loader
googleAdsense googleAdsense PRIVACY_HEATMAP Path A, shared GA proxy config
metaPixel metaPixel PRIVACY_FULL Path A
tiktokPixel tiktokPixel n/a Direct loader and collection
xPixel xPixel PRIVACY_FULL Path A
snapchatPixel snapchatPixel PRIVACY_FULL Path A
redditPixel redditPixel PRIVACY_FULL Path A
linkedinInsight linkedinInsight PRIVACY_FULL Path A
ahrefsAnalytics ahrefsAnalytics PRIVACY_IP_ONLY Path A
clarity clarity PRIVACY_HEATMAP Path A
hotjar hotjar PRIVACY_HEATMAP 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 n/a Bundled script, direct collection
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
gravatar gravatar PRIVACY_IP_ONLY Path A
calendly calendly PRIVACY_IP_ONLY Path A
googleTagManager googleTagManager n/a Direct loader and runtime scripts
segment segment n/a Bundle only
crisp crisp n/a Bundle only
speedcurve speedcurve n/a No proxy (ID-parameterized CDN URL)

Excluded from first-party mode (proxy: false)

Script Reason
stripe Fraud detection requires real fingerprints
paypal Fraud detection requires real fingerprints
googleRecaptcha Bot detection requires real fingerprints
googleSignIn Auth integrity requires direct connection

Design Notes

Domain-based proxy routing

Each proxy config declares domains[] — the list of third-party domains that script communicates with. The transform plugin derives rewrite rules at build time as { from: domain, to: proxyPrefix/domain }. The server handler extracts the domain from the proxy path and forwards to the upstream.

Runtime interception

Bundled SDKs call __nuxtScripts wrappers after AST rewriting. Google Analytics uses its configured collection endpoint. Native application requests retain their original URLs.

The server handler extracts the target domain directly from the proxy path (/_scripts/p/<host>/<path>) and looks up privacy config by domain. Unrecognized domains default to full anonymization (fail-closed).

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 proxy: false opt-out.

The transform plugin receives the pre-built proxyConfigs map via options — direct lookup per-script, no rebuilding.

Registry entry normalization (true/'mock'/object/array → [input, scriptOptions?] tuple) is handled once at module setup by normalizeRegistryConfig() (src/normalize.ts). All downstream consumers (env defaults, template plugin, auto-inject, partytown) receive a single shape.

No mutable closure pattern

The intercept plugin is registered with a static proxyPrefix string — no mutable array captured by reference. Plugin generation is a pure function of its inputs.

firstParty: true default

Default is true. For nuxt generate and static presets, a warning fires with actionable guidance.

Privacy defaults

GA defaults (PRIVACY_HEATMAP): anonymizes ip/language/hardware, passes through userAgent/screen/timezone. UA string is needed for Browser/OS/Device reports; hardware flag covers cross-site fingerprinting vectors (canvas/WebGL/plugins/fonts/high-entropy client hints) that GA doesn't need for standard reports.

hardware: true also strips high-entropy Client Hints (sec-ch-ua-arch, sec-ch-ua-model) which GA4 is migrating toward for device reporting.

Key files

  • src/normalize.ts — normalizes registry config entries to [input, scriptOptions?] tuple form
  • src/first-party/proxy-configs.ts — all proxy configs with domains[] + privacy presets
  • src/first-party/setup.ts — orchestration (setupFirstParty, finalizeFirstParty, applyAutoInject)
  • src/first-party/intercept-plugin.ts — client-side __nuxtScripts wrapper (proxyUrl for non-same-origin URLs)
  • src/first-party/types.ts — FirstPartyOptions, ProxyConfig, ProxyAutoInject
  • src/registry.ts — script metadata (labels, imports, bundling, proxy: false opt-out); registryKey = proxy config lookup key
  • src/registry-logos.ts — SVG logos extracted from registry for smaller diffs
  • src/runtime/server/proxy-handler.ts — server-side proxy with domain extraction + privacy transforms
  • src/runtime/server/utils/privacy.ts — privacy resolution, IP anonymization, UA normalization, payload stripping
  • src/plugins/rewrite-ast.ts — AST URL rewriting + canvas fingerprinting neutralization (generic); SDK-specific patches in postProcess
  • src/plugins/transform.ts — build-time script download + URL rewriting, passes postProcess through
  • src/module.ts — ModuleOptions, defaults
  • docs/content/docs/1.guides/2.first-party.md — main docs page

Config options

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