From 906ad46768607f636d982ae42bfb03a286c1b5a0 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 01:58:48 +1100 Subject: [PATCH 1/5] Squash commits from main Combined commits: - fix(plausible): use consistent window reference in clientInit stub --- .claude/plans/543-plausible-broken.md | 61 +++++++++++++++++++++ src/runtime/registry/plausible-analytics.ts | 14 +++-- 2 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 .claude/plans/543-plausible-broken.md diff --git a/.claude/plans/543-plausible-broken.md b/.claude/plans/543-plausible-broken.md new file mode 100644 index 00000000..ea85c8ed --- /dev/null +++ b/.claude/plans/543-plausible-broken.md @@ -0,0 +1,61 @@ +## DONE + +# Fix: Plausible Analytics new script not working (#543) + +## Problem +Users report that the new Plausible script format (with `scriptId`) loads correctly in DOM but no events are sent. + +## Analysis + +### Root Cause +The `clientInit` stub uses bare `plausible` identifier inconsistently with `window.plausible`: + +```js +// Current code (plausible-analytics.ts:187) +window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} } +``` + +Issues: +1. **Inconsistent window reference**: First part uses `window.plausible`, second part uses bare `plausible` +2. **Module scope**: In ES modules (strict mode), bare identifier resolution differs from non-module scripts +3. **Compare to GA**: Google Analytics uses `w` (window) consistently throughout its clientInit + +### How Plausible's new script works +The `pa-{scriptId}.js` script: +1. Checks `plausible.o && S(plausible.o)` on load to pick up pre-init options +2. The stub's `plausible.init()` stores options in `plausible.o` +3. Script has domain hardcoded, doesn't need `data-domain` attribute + +### Verification +Plausible script expected stub format: +```js +window.plausible = window.plausible || {} +plausible.o && S(plausible.o) // If .o exists, initialize with those options +``` + +Our stub needs to set `plausible.o` before script loads, which it does via: +```js +plausible.init = function(i) { plausible.o = i || {} } +window.plausible.init(initOptions) +``` + +## Fix + +Update `clientInit` to use `window.plausible` consistently (like GA does): + +```ts +clientInit() { + const w = window as any + w.plausible = w.plausible || function () { (w.plausible.q = w.plausible.q || []).push(arguments) } + w.plausible.init = w.plausible.init || function (i: PlausibleInitOptions) { w.plausible.o = i || {} } + w.plausible.init(initOptions) +} +``` + +## Files to modify +- `src/runtime/registry/plausible-analytics.ts`: Fix clientInit stub pattern + +## Test plan +1. Run existing tests +2. Test playground with plausible-analytics-v2.vue +3. Verify script loads and init options are picked up diff --git a/src/runtime/registry/plausible-analytics.ts b/src/runtime/registry/plausible-analytics.ts index 9a955032..17ac9c95 100644 --- a/src/runtime/registry/plausible-analytics.ts +++ b/src/runtime/registry/plausible-analytics.ts @@ -181,12 +181,14 @@ export function useScriptPlausibleAnalytics(_op use() { return { plausible: window.plausible } }, - clientInit() { - // @ts-expect-error untyped - // eslint-disable-next-line @typescript-eslint/no-unused-expressions,@stylistic/max-statements-per-line,prefer-rest-params - window.plausible = window.plausible || function () { (plausible.q = plausible.q || []).push(arguments) }, plausible.init = plausible.init || function (i) { plausible.o = i || {} } - window.plausible.init(initOptions) - }, + clientInit: import.meta.server + ? undefined + : () => { + const w = window as any + w.plausible = w.plausible || function () { (w.plausible.q = w.plausible.q || []).push(arguments) } + w.plausible.init = w.plausible.init || function (i: PlausibleInitOptions) { w.plausible.o = i || {} } + w.plausible.init(initOptions) + }, }, } }, _options) From 7ef19decb7001ff5bac9d2180ccee0f5ec6bec25 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 02:59:55 +1100 Subject: [PATCH 2/5] 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 --- docs/content/docs/1.guides/2.first-party.md | 132 ++++++++++ .../1.guides/{2.bundling.md => 3.bundling.md} | 4 + src/module.ts | 90 ++++++- src/plugins/transform.ts | 52 +++- src/proxy-configs.ts | 164 +++++++++++++ src/registry.ts | 10 + src/runtime/types.ts | 20 ++ test/unit/proxy-configs.test.ts | 192 +++++++++++++++ .../third-party-proxy-replacements.test.ts | 230 ++++++++++++++++++ 9 files changed, 883 insertions(+), 11 deletions(-) create mode 100644 docs/content/docs/1.guides/2.first-party.md rename docs/content/docs/1.guides/{2.bundling.md => 3.bundling.md} (96%) create mode 100644 src/proxy-configs.ts create mode 100644 test/unit/proxy-configs.test.ts create mode 100644 test/unit/third-party-proxy-replacements.test.ts diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md new file mode 100644 index 00000000..1fe38d8a --- /dev/null +++ b/docs/content/docs/1.guides/2.first-party.md @@ -0,0 +1,132 @@ +--- +title: First-Party Mode +description: Route third-party script traffic through your domain for improved privacy and reliability. +--- + +## Background + +When third-party scripts load directly from external servers, they expose your users' data: + +- **IP address exposure** - Every request reveals your users' IP addresses to third parties +- **Third-party cookies** - External scripts can set cookies for cross-site tracking +- **Ad blocker interference** - Privacy tools block requests to known tracking domains +- **Connection overhead** - Extra DNS lookups and TLS handshakes slow page loads + +### How First-Party Mode Helps + +First-party mode routes all script traffic through your domain: + +- **User IPs stay private** - Third parties see your server's IP, not your users' +- **No third-party cookies** - Requests are same-origin, eliminating cross-site tracking +- **Works with ad blockers** - Requests appear first-party +- **Faster loads** - No extra DNS lookups for external domains + +## How it Works + +When first-party mode is enabled: + +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`) +2. **Runtime**: Nitro route rules proxy requests from local paths back to original endpoints + +``` +User Browser → Your Server (/_scripts/c/ga/...) → Google Analytics +``` + +Your users never connect directly to third-party servers. + +## Usage + +### Enable Globally + +Enable first-party mode for all supported scripts: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + firstParty: true, + registry: { + googleAnalytics: { id: 'G-XXXXXX' }, + metaPixel: { id: '123456' }, + } + } +}) +``` + +### Custom Paths + +Customize the proxy endpoint paths: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + firstParty: { + collectPrefix: '/_analytics', // Default: /_scripts/c + } + } +}) +``` + +### Opt-out Per Script + +Disable first-party routing for a specific script: + +```ts +useScriptGoogleAnalytics({ + id: 'G-XXXXXX', + scriptOptions: { + firstParty: false, // Load directly from Google + } +}) +``` + +## Supported Scripts + +First-party mode supports the following scripts: + +| Script | Endpoints Routed | +|--------|------------------| +| Google Analytics | `www.google.com/g/collect`, `www.google-analytics.com` | +| Google Tag Manager | `www.googletagmanager.com` | +| Meta Pixel | `connect.facebook.net`, `www.facebook.com/tr` | +| TikTok Pixel | `analytics.tiktok.com` | +| Segment | `api.segment.io`, `cdn.segment.com` | +| Microsoft Clarity | `www.clarity.ms` | +| Hotjar | `static.hotjar.com`, `vars.hotjar.com` | +| X/Twitter Pixel | `analytics.twitter.com`, `t.co` | +| Snapchat Pixel | `tr.snapchat.com` | +| Reddit Pixel | `alb.reddit.com` | + +## Requirements + +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. + +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. + +### Static Hosting Rewrites + +If deploying statically, configure your platform to proxy these paths: + +``` +/_scripts/c/ga/* → https://www.google.com/* +/_scripts/c/gtm/* → https://www.googletagmanager.com/* +/_scripts/c/meta/* → https://connect.facebook.net/* +``` + +## First-Party vs Bundle + +First-party mode supersedes the `bundle` option: + +| Feature | `bundle: true` | `firstParty: true` | +|---------|---------------|-------------------| +| Downloads script at build | ✅ | ✅ | +| Serves from your domain | ✅ | ✅ | +| Rewrites collection URLs | ❌ | ✅ | +| Proxies API requests | ❌ | ✅ | +| Hides user IPs | ❌ | ✅ | +| Blocks third-party cookies | ❌ | ✅ | + +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. + +::callout{type="warning"} +The `bundle` option is deprecated. Use `firstParty: true` for new projects. +:: diff --git a/docs/content/docs/1.guides/2.bundling.md b/docs/content/docs/1.guides/3.bundling.md similarity index 96% rename from docs/content/docs/1.guides/2.bundling.md rename to docs/content/docs/1.guides/3.bundling.md index c0e6cd28..b77dd622 100644 --- a/docs/content/docs/1.guides/2.bundling.md +++ b/docs/content/docs/1.guides/3.bundling.md @@ -3,6 +3,10 @@ title: Bundling Remote Scripts description: Optimize third-party scripts by bundling them with your app. --- +::callout{type="warning"} +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. +:: + ## Background 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. diff --git a/src/module.ts b/src/module.ts index dd8acff1..bd4fef52 100644 --- a/src/module.ts +++ b/src/module.ts @@ -26,8 +26,31 @@ import type { } from './runtime/types' import { NuxtScriptsCheckScripts } from './plugins/check-scripts' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' +import { getAllProxyConfigs, type ProxyConfig } from './proxy-configs' + +export interface FirstPartyOptions { + /** + * Path prefix for serving bundled scripts. + * @default '/_scripts' + */ + prefix?: string + /** + * Path prefix for collection proxy endpoints. + * @default '/_scripts/c' + */ + collectPrefix?: string +} export interface ModuleOptions { + /** + * Route third-party scripts through your domain for improved privacy. + * When enabled, scripts are downloaded at build time and served from your domain. + * Collection endpoints (analytics, pixels) are also routed through your server, + * keeping user IPs private and eliminating third-party cookies. + * + * @default false + */ + firstParty?: boolean | FirstPartyOptions /** * The registry of supported third-party scripts. Loads the scripts in globally using the default script options. */ @@ -147,6 +170,26 @@ export default defineNuxtModule({ ) } + // Handle deprecation of bundle option - migrate to firstParty + if (config.defaultScriptOptions?.bundle !== undefined) { + logger.warn( + '`scripts.defaultScriptOptions.bundle` is deprecated. ' + + 'Use `scripts.firstParty: true` instead.', + ) + // Migrate: treat bundle as firstParty + if (!config.firstParty && config.defaultScriptOptions.bundle) { + config.firstParty = true + } + } + + // Resolve first-party configuration + const firstPartyEnabled = !!config.firstParty + const firstPartyPrefix = typeof config.firstParty === 'object' ? config.firstParty.prefix : undefined + const firstPartyCollectPrefix = typeof config.firstParty === 'object' + ? config.firstParty.collectPrefix || '/_scripts/c' + : '/_scripts/c' + const assetsPrefix = firstPartyPrefix || config.assets?.prefix || '/_scripts' + const composables = [ 'useScript', 'useScriptEventPage', @@ -214,6 +257,47 @@ export default defineNuxtModule({ } const { renderedScript } = setupPublicAssetStrategy(config.assets) + // Inject proxy route rules if first-party mode is enabled + if (firstPartyEnabled) { + const proxyConfigs = getAllProxyConfigs(firstPartyCollectPrefix) + const registryKeys = Object.keys(config.registry || {}) + + // Collect routes for all configured registry scripts that support proxying + const neededRoutes: Record = {} + for (const key of registryKeys) { + // Find the registry script definition + const script = registryScriptsWithImport.find(s => s.import.name === `useScript${key.charAt(0).toUpperCase() + key.slice(1)}`) + // Use script's proxy field if defined, otherwise fall back to registry key + // If proxy is explicitly false, skip this script entirely + const proxyKey = script?.proxy !== false ? (script?.proxy || key) : undefined + if (proxyKey) { + const proxyConfig = proxyConfigs[proxyKey] + if (proxyConfig?.routes) { + Object.assign(neededRoutes, proxyConfig.routes) + } + } + } + + // Inject route rules + if (Object.keys(neededRoutes).length) { + nuxt.options.routeRules = { + ...nuxt.options.routeRules, + ...neededRoutes, + } + } + + // Warn for static presets + const preset = nuxt.options.nitro?.preset || process.env.NITRO_PRESET || '' + const staticPresets = ['static', 'github-pages', 'cloudflare-pages-static'] + if (staticPresets.includes(preset)) { + logger.warn( + 'Proxy collection endpoints require a server runtime. ' + + 'Scripts will be bundled but collection requests will not be proxied. ' + + 'See https://scripts.nuxt.com/docs/guides/proxy for manual platform rewrite configuration.', + ) + } + } + const moduleInstallPromises: Map Promise | undefined> = new Map() addBuildPlugin(NuxtScriptsCheckScripts(), { @@ -222,12 +306,14 @@ export default defineNuxtModule({ addBuildPlugin(NuxtScriptBundleTransformer({ scripts: registryScriptsWithImport, registryConfig: nuxt.options.runtimeConfig.public.scripts as Record | undefined, - defaultBundle: config.defaultScriptOptions?.bundle, + defaultBundle: firstPartyEnabled || config.defaultScriptOptions?.bundle, + firstPartyEnabled, + firstPartyCollectPrefix, moduleDetected(module) { if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module)) moduleInstallPromises.set(module, () => installNuxtModule(module)) }, - assetsBaseURL: config.assets?.prefix, + assetsBaseURL: assetsPrefix, fallbackOnSrcOnBundleFail: config.assets?.fallbackOnSrcOnBundleFail, fetchOptions: config.assets?.fetchOptions, cacheMaxAge: config.assets?.cacheMaxAge, diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 2e4917e3..e12f9cd3 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -15,6 +15,7 @@ import type { FetchOptions } from 'ofetch' import { $fetch } from 'ofetch' import { logger } from '../logger' import { bundleStorage } from '../assets' +import { getProxyConfig, rewriteScriptUrls, type ProxyRewrite } from '../proxy-configs' import { isJS, isVue } from './util' import type { RegistryScript } from '#nuxt-scripts/types' @@ -39,6 +40,14 @@ export interface AssetBundlerTransformerOptions { * Used to provide default options to script bundling functions when no arguments are provided */ registryConfig?: Record + /** + * Whether first-party mode is enabled + */ + firstPartyEnabled?: boolean + /** + * Path prefix for collection proxy endpoints + */ + firstPartyCollectPrefix?: string fallbackOnSrcOnBundleFail?: boolean fetchOptions?: FetchOptions cacheMaxAge?: number @@ -74,8 +83,9 @@ async function downloadScript(opts: { url: string filename?: string forceDownload?: boolean + proxyRewrites?: ProxyRewrite[] }, renderedScript: NonNullable, fetchOptions?: FetchOptions, cacheMaxAge?: number) { - const { src, url, filename, forceDownload } = opts + const { src, url, filename, forceDownload, proxyRewrites } = opts if (src === url || !filename) { return } @@ -84,7 +94,8 @@ async function downloadScript(opts: { let res: Buffer | undefined = scriptContent instanceof Error ? undefined : scriptContent?.content if (!res) { // Use storage to cache the font data between builds - const cacheKey = `bundle:${filename}` + // Include proxy in cache key to differentiate proxied vs non-proxied versions + const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename}` : `bundle:${filename}` const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge)) if (shouldUseCache) { @@ -111,7 +122,15 @@ async function downloadScript(opts: { return Buffer.from(r._data || await r.arrayBuffer()) }) - await storage.setItemRaw(`bundle:${filename}`, res) + // Apply URL rewrites for proxy mode + if (proxyRewrites?.length && res) { + const content = res.toString('utf-8') + const rewritten = rewriteScriptUrls(content, proxyRewrites) + res = Buffer.from(rewritten, 'utf-8') + logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`) + } + + await storage.setItemRaw(cacheKey, res) // Save metadata with timestamp for cache expiration await storage.setItem(`bundle-meta:${filename}`, { timestamp: Date.now(), @@ -195,6 +214,12 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti const node = _node as SimpleCallExpression let scriptSrcNode: Literal & { start: number, end: number } | undefined let src: false | string | undefined + // Compute registryKey for proxy config lookup + let registryKey: string | undefined + if (fnName !== 'useScript') { + const baseName = fnName.replace(/^useScript/, '') + registryKey = baseName.length > 0 ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : undefined + } if (fnName === 'useScript') { // do easy case first where first argument is a literal if (node.arguments[0]?.type === 'Literal') { @@ -219,12 +244,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti return // integration case - // Get registry key from function name (e.g., useScriptGoogleTagManager -> googleTagManager) - const baseName = fnName.replace(/^useScript/, '') - const registryKey = baseName.length > 0 ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : '' - // Get registry config for this script - const registryConfig = options.registryConfig?.[registryKey] || {} + const registryConfig = options.registryConfig?.[registryKey || ''] || {} const fnArg0 = {} @@ -331,11 +352,24 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti canBundle = bundleValue === true || bundleValue === 'force' || String(bundleValue) === 'true' forceDownload = bundleValue === 'force' } + // Check for per-script first-party opt-out (firstParty: false) + // @ts-expect-error untyped + const firstPartyOption = scriptOptions?.value.properties?.find((prop) => { + return prop.type === 'Property' && prop.key?.name === 'firstParty' && prop.value.type === 'Literal' + }) + const firstPartyOptOut = firstPartyOption?.value.value === false if (canBundle) { const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL) let url = _url + // Get proxy rewrites if first-party is enabled, not opted out, and script supports it + // Use script's proxy field if defined, otherwise fall back to registry key + const script = options.scripts.find(s => s.import.name === fnName) + const proxyConfigKey = script?.proxy !== false ? (script?.proxy || registryKey) : undefined + const proxyRewrites = options.firstPartyEnabled && !firstPartyOptOut && proxyConfigKey && options.firstPartyCollectPrefix + ? getProxyConfig(proxyConfigKey, options.firstPartyCollectPrefix)?.rewrite + : undefined try { - await downloadScript({ src, url, filename, forceDownload }, renderedScript, options.fetchOptions, options.cacheMaxAge) + await downloadScript({ src, url, filename, forceDownload, proxyRewrites }, renderedScript, options.fetchOptions, options.cacheMaxAge) } catch (e: any) { if (options.fallbackOnSrcOnBundleFail) { diff --git a/src/proxy-configs.ts b/src/proxy-configs.ts new file mode 100644 index 00000000..46f570b3 --- /dev/null +++ b/src/proxy-configs.ts @@ -0,0 +1,164 @@ +/** + * Proxy configuration for third-party scripts. + * Defines URL rewrites and route rules for proxying collection endpoints. + */ +export interface ProxyRewrite { + /** Domain to match and replace */ + from: string + /** Local path to rewrite to */ + to: string +} + +export interface ProxyConfig { + /** URL rewrites to apply to downloaded script content */ + rewrite?: ProxyRewrite[] + /** Nitro route rules to inject for proxying requests */ + routes?: Record +} + +/** + * Builds proxy config with the configured collect prefix. + */ +function buildProxyConfig(collectPrefix: string) { + return { + googleAnalytics: { + rewrite: [ + // Modern gtag.js uses www.google.com/g/collect + { from: 'www.google.com/g/collect', to: `${collectPrefix}/ga/g/collect` }, + // Legacy endpoints still used by some scripts + { from: 'www.google-analytics.com', to: `${collectPrefix}/ga-legacy` }, + { from: 'analytics.google.com', to: `${collectPrefix}/ga-legacy` }, + ], + routes: { + [`${collectPrefix}/ga/**`]: { proxy: 'https://www.google.com/**' }, + [`${collectPrefix}/ga-legacy/**`]: { proxy: 'https://www.google-analytics.com/**' }, + }, + }, + + googleTagManager: { + rewrite: [ + { from: 'www.googletagmanager.com', to: `${collectPrefix}/gtm` }, + ], + routes: { + [`${collectPrefix}/gtm/**`]: { proxy: 'https://www.googletagmanager.com/**' }, + }, + }, + + metaPixel: { + rewrite: [ + { from: 'connect.facebook.net', to: `${collectPrefix}/meta` }, + { from: 'www.facebook.com/tr', to: `${collectPrefix}/meta/tr` }, + ], + routes: { + [`${collectPrefix}/meta/**`]: { proxy: 'https://connect.facebook.net/**' }, + }, + }, + + tiktokPixel: { + rewrite: [ + { from: 'analytics.tiktok.com', to: `${collectPrefix}/tiktok` }, + ], + routes: { + [`${collectPrefix}/tiktok/**`]: { proxy: 'https://analytics.tiktok.com/**' }, + }, + }, + + segment: { + rewrite: [ + { from: 'api.segment.io', to: `${collectPrefix}/segment` }, + { from: 'cdn.segment.com', to: `${collectPrefix}/segment-cdn` }, + ], + routes: { + [`${collectPrefix}/segment/**`]: { proxy: 'https://api.segment.io/**' }, + [`${collectPrefix}/segment-cdn/**`]: { proxy: 'https://cdn.segment.com/**' }, + }, + }, + + xPixel: { + rewrite: [ + { from: 'analytics.twitter.com', to: `${collectPrefix}/x` }, + { from: 't.co', to: `${collectPrefix}/x-t` }, + ], + routes: { + [`${collectPrefix}/x/**`]: { proxy: 'https://analytics.twitter.com/**' }, + [`${collectPrefix}/x-t/**`]: { proxy: 'https://t.co/**' }, + }, + }, + + snapchatPixel: { + rewrite: [ + { from: 'tr.snapchat.com', to: `${collectPrefix}/snap` }, + ], + routes: { + [`${collectPrefix}/snap/**`]: { proxy: 'https://tr.snapchat.com/**' }, + }, + }, + + redditPixel: { + rewrite: [ + { from: 'alb.reddit.com', to: `${collectPrefix}/reddit` }, + ], + routes: { + [`${collectPrefix}/reddit/**`]: { proxy: 'https://alb.reddit.com/**' }, + }, + }, + + clarity: { + rewrite: [ + { from: 'www.clarity.ms', to: `${collectPrefix}/clarity` }, + ], + routes: { + [`${collectPrefix}/clarity/**`]: { proxy: 'https://www.clarity.ms/**' }, + }, + }, + + hotjar: { + rewrite: [ + { from: 'static.hotjar.com', to: `${collectPrefix}/hotjar` }, + { from: 'vars.hotjar.com', to: `${collectPrefix}/hotjar-vars` }, + ], + routes: { + [`${collectPrefix}/hotjar/**`]: { proxy: 'https://static.hotjar.com/**' }, + [`${collectPrefix}/hotjar-vars/**`]: { proxy: 'https://vars.hotjar.com/**' }, + }, + }, + } satisfies Record +} + +export type ProxyConfigKey = keyof ReturnType + +/** + * Get proxy config for a specific script. + */ +export function getProxyConfig(key: string, collectPrefix: string): ProxyConfig | undefined { + const configs = buildProxyConfig(collectPrefix) + return configs[key as ProxyConfigKey] +} + +/** + * Get all proxy configs. + */ +export function getAllProxyConfigs(collectPrefix: string): Record { + return buildProxyConfig(collectPrefix) +} + +/** + * Rewrite URLs in script content based on proxy config. + */ +export function rewriteScriptUrls(content: string, rewrites: ProxyRewrite[]): string { + let result = content + for (const { from, to } of rewrites) { + // Rewrite various URL formats + result = result + .replaceAll(`"https://${from}`, `"${to}`) + .replaceAll(`'https://${from}`, `'${to}`) + .replaceAll(`\`https://${from}`, `\`${to}`) + .replaceAll(`"http://${from}`, `"${to}`) + .replaceAll(`'http://${from}`, `'${to}`) + .replaceAll(`\`http://${from}`, `\`${to}`) + .replaceAll(`"//${from}`, `"${to}`) + .replaceAll(`'//${from}`, `'${to}`) + .replaceAll(`\`//${from}`, `\`${to}`) + } + return result +} diff --git a/src/registry.ts b/src/registry.ts index 06336d36..5e473f71 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -91,6 +91,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Segment', + proxy: 'segment', scriptBundling: (options?: SegmentInput) => { return joinURL('https://cdn.segment.com/analytics.js/v1', options?.writeKey || '', 'analytics.min.js') }, @@ -103,6 +104,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Meta Pixel', + proxy: 'metaPixel', src: 'https://connect.facebook.net/en_US/fbevents.js', category: 'tracking', logo: ``, @@ -113,6 +115,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'X Pixel', + proxy: 'xPixel', src: 'https://static.ads-twitter.com/uwt.js', category: 'tracking', logo: { @@ -126,6 +129,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'TikTok Pixel', + proxy: 'tiktokPixel', category: 'tracking', logo: ``, import: { @@ -140,6 +144,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Snapchat Pixel', + proxy: 'snapchatPixel', src: 'https://sc-static.net/scevent.min.js', category: 'tracking', logo: '', @@ -150,6 +155,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Reddit Pixel', + proxy: 'redditPixel', src: 'https://www.redditstatic.com/ads/pixel.js', category: 'tracking', logo: ` `, @@ -200,6 +206,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Hotjar', + proxy: 'hotjar', scriptBundling(options?: HotjarInput) { if (!options?.id) { return false @@ -217,6 +224,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption }, { label: 'Clarity', + proxy: 'clarity', scriptBundling(options?: ClarityInput) { if (!options?.id) { return false @@ -348,6 +356,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption { label: 'Google Tag Manager', category: 'tracking', + proxy: 'googleTagManager', import: { name: 'useScriptGoogleTagManager', from: await resolve('./runtime/registry/google-tag-manager'), @@ -374,6 +383,7 @@ export async function registry(resolve?: (path: string, opts?: ResolvePathOption { label: 'Google Analytics', category: 'analytics', + proxy: 'googleAnalytics', import: { name: 'useScriptGoogleAnalytics', from: await resolve('./runtime/registry/google-analytics'), diff --git a/src/runtime/types.ts b/src/runtime/types.ts index ba941353..31630fc9 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -57,8 +57,17 @@ export type NuxtUseScriptOptions = {}> = * - `false` - Do not bundle the script. (default) * * Note: Using 'force' may significantly increase build time as scripts will be re-downloaded on every build. + * + * @deprecated Use `scripts.firstParty: true` in nuxt.config instead for bundling and routing scripts through your domain. */ bundle?: boolean | 'force' + /** + * Opt-out of first-party routing for this specific script when global `scripts.firstParty` is enabled. + * Set to `false` to load this script directly from its original source instead of through your domain. + * + * Note: This option only works as an opt-out. To enable first-party routing, use the global `scripts.firstParty` option in nuxt.config. + */ + firstParty?: false /** * Skip any schema validation for the script input. This is useful for loading the script stubs for development without * loading the actual script and not getting warnings. @@ -210,6 +219,17 @@ export type RegistryScriptInput< export interface RegistryScript { import?: Import // might just be a component scriptBundling?: false | ((options?: any) => string | false) + /** + * First-party routing configuration for this script. + * - `string` - The proxy config key to use (e.g., 'googleAnalytics', 'metaPixel') + * - `false` - Explicitly disable first-party routing for this script + * - `undefined` - Use the default key derived from the function name + * + * When set to a string, the script's URLs will be rewritten and collection + * endpoints will be routed through your server when `scripts.firstParty` is enabled. + * @internal + */ + proxy?: string | false label?: string src?: string | false category?: string diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts new file mode 100644 index 00000000..86f69619 --- /dev/null +++ b/test/unit/proxy-configs.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest' +import { getAllProxyConfigs, getProxyConfig, rewriteScriptUrls } from '../../src/proxy-configs' + +describe('proxy configs', () => { + describe('rewriteScriptUrls', () => { + it('rewrites https URLs with double quotes', () => { + const input = `fetch("https://www.google-analytics.com/g/collect")` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe(`fetch("/_scripts/c/ga/g/collect")`) + }) + + it('rewrites https URLs with single quotes', () => { + const input = `url='https://www.google-analytics.com/analytics.js'` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe(`url='/_scripts/c/ga/analytics.js'`) + }) + + it('rewrites https URLs with backticks', () => { + const input = 'const u=`https://www.google-analytics.com/collect`' + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe('const u=`/_scripts/c/ga/collect`') + }) + + it('rewrites protocol-relative URLs', () => { + const input = `"//www.google-analytics.com/analytics.js"` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe(`"/_scripts/c/ga/analytics.js"`) + }) + + it('rewrites http URLs', () => { + const input = `"http://www.google-analytics.com/analytics.js"` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe(`"/_scripts/c/ga/analytics.js"`) + }) + + it('handles multiple rewrites in single content', () => { + const input = ` + fetch("https://www.google-analytics.com/g/collect"); + fetch("https://analytics.google.com/collect"); + ` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + { from: 'analytics.google.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toContain(`"/_scripts/c/ga/g/collect"`) + expect(output).toContain(`"/_scripts/c/ga/collect"`) + }) + + it('handles GTM URLs', () => { + const input = `src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXX"` + const output = rewriteScriptUrls(input, [ + { from: 'www.googletagmanager.com', to: '/_scripts/c/gtm' }, + ]) + expect(output).toBe(`src="/_scripts/c/gtm/gtm.js?id=GTM-XXXX"`) + }) + + it('handles Meta Pixel URLs', () => { + const input = `"https://connect.facebook.net/en_US/fbevents.js"` + const output = rewriteScriptUrls(input, [ + { from: 'connect.facebook.net', to: '/_scripts/c/meta' }, + ]) + expect(output).toBe(`"/_scripts/c/meta/en_US/fbevents.js"`) + }) + + it('returns unmodified content when no matches', () => { + const input = `fetch("https://example.com/api")` + const output = rewriteScriptUrls(input, [ + { from: 'www.google-analytics.com', to: '/_scripts/c/ga' }, + ]) + expect(output).toBe(input) + }) + }) + + describe('getProxyConfig', () => { + it('returns proxy config for googleAnalytics', () => { + const config = getProxyConfig('googleAnalytics', '/_scripts/c') + expect(config).toBeDefined() + expect(config?.rewrite).toBeDefined() + expect(config?.routes).toBeDefined() + // Modern GA4 endpoint + expect(config?.rewrite).toContainEqual({ + from: 'www.google.com/g/collect', + to: '/_scripts/c/ga/g/collect', + }) + // Legacy endpoint + expect(config?.rewrite).toContainEqual({ + from: 'www.google-analytics.com', + to: '/_scripts/c/ga-legacy', + }) + }) + + it('returns proxy config for googleTagManager', () => { + const config = getProxyConfig('googleTagManager', '/_scripts/c') + expect(config).toBeDefined() + expect(config?.rewrite).toContainEqual({ + from: 'www.googletagmanager.com', + to: '/_scripts/c/gtm', + }) + }) + + it('returns proxy config for metaPixel', () => { + const config = getProxyConfig('metaPixel', '/_scripts/c') + expect(config).toBeDefined() + expect(config?.rewrite).toContainEqual({ + from: 'connect.facebook.net', + to: '/_scripts/c/meta', + }) + }) + + it('returns undefined for unsupported scripts', () => { + const config = getProxyConfig('unknownScript', '/_scripts/c') + expect(config).toBeUndefined() + }) + + it('uses custom collectPrefix', () => { + const config = getProxyConfig('googleAnalytics', '/_custom/proxy') + // Modern GA4 endpoint with custom prefix + expect(config?.rewrite).toContainEqual({ + from: 'www.google.com/g/collect', + to: '/_custom/proxy/ga/g/collect', + }) + // Legacy endpoint with custom prefix + expect(config?.rewrite).toContainEqual({ + from: 'www.google-analytics.com', + to: '/_custom/proxy/ga-legacy', + }) + expect(config?.routes).toHaveProperty('/_custom/proxy/ga/**') + expect(config?.routes).toHaveProperty('/_custom/proxy/ga-legacy/**') + }) + }) + + describe('getAllProxyConfigs', () => { + it('returns all proxy configs', () => { + const configs = getAllProxyConfigs('/_scripts/c') + expect(configs).toHaveProperty('googleAnalytics') + expect(configs).toHaveProperty('googleTagManager') + expect(configs).toHaveProperty('metaPixel') + expect(configs).toHaveProperty('tiktokPixel') + expect(configs).toHaveProperty('segment') + expect(configs).toHaveProperty('clarity') + expect(configs).toHaveProperty('hotjar') + }) + + it('all configs have valid structure', () => { + const configs = getAllProxyConfigs('/_scripts/c') + for (const [key, config] of Object.entries(configs)) { + expect(config, `${key} should have routes`).toHaveProperty('routes') + expect(config, `${key} should have rewrite`).toHaveProperty('rewrite') + expect(Array.isArray(config.rewrite), `${key}.rewrite should be an array`).toBe(true) + expect(typeof config.routes, `${key}.routes should be an object`).toBe('object') + } + }) + }) + + describe('route rules structure', () => { + it('googleAnalytics routes proxy to correct target', () => { + const config = getProxyConfig('googleAnalytics', '/_scripts/c') + // Modern GA4 endpoint + expect(config?.routes?.['/_scripts/c/ga/**']).toEqual({ + proxy: 'https://www.google.com/**', + }) + // Legacy endpoint + expect(config?.routes?.['/_scripts/c/ga-legacy/**']).toEqual({ + proxy: 'https://www.google-analytics.com/**', + }) + }) + + it('googleTagManager routes proxy to correct target', () => { + const config = getProxyConfig('googleTagManager', '/_scripts/c') + expect(config?.routes?.['/_scripts/c/gtm/**']).toEqual({ + proxy: 'https://www.googletagmanager.com/**', + }) + }) + + it('metaPixel routes proxy to correct target', () => { + const config = getProxyConfig('metaPixel', '/_scripts/c') + expect(config?.routes?.['/_scripts/c/meta/**']).toEqual({ + proxy: 'https://connect.facebook.net/**', + }) + }) + }) +}) diff --git a/test/unit/third-party-proxy-replacements.test.ts b/test/unit/third-party-proxy-replacements.test.ts new file mode 100644 index 00000000..53ae60bd --- /dev/null +++ b/test/unit/third-party-proxy-replacements.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, it } from 'vitest' +import { $fetch } from 'ofetch' +import { getAllProxyConfigs, rewriteScriptUrls } from '../../src/proxy-configs' + +const COLLECT_PREFIX = '/_scripts/c' + +interface ScriptTestCase { + name: string + url: string + registryKey: string + expectedPatterns: string[] // patterns that should exist BEFORE rewrite + forbiddenAfterRewrite: string[] // domains that should NOT exist after rewrite +} + +const testCases: ScriptTestCase[] = [ + { + name: 'Google Analytics (gtag.js)', + url: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX', + registryKey: 'googleAnalytics', + // Modern gtag.js uses www.google.com/g/collect for analytics + expectedPatterns: ['www.google.com/g/collect', 'googletagmanager.com'], + forbiddenAfterRewrite: ['www.google.com/g/collect'], + }, + { + name: 'Google Tag Manager', + url: 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX', + registryKey: 'googleTagManager', + expectedPatterns: ['googletagmanager.com'], + forbiddenAfterRewrite: ['www.googletagmanager.com'], + }, + { + name: 'Meta Pixel (fbevents.js)', + url: 'https://connect.facebook.net/en_US/fbevents.js', + registryKey: 'metaPixel', + expectedPatterns: ['facebook'], + forbiddenAfterRewrite: ['connect.facebook.net'], + }, + { + name: 'TikTok Pixel', + url: 'https://analytics.tiktok.com/i18n/pixel/events.js?sdkid=XXXXXXX', + registryKey: 'tiktokPixel', + expectedPatterns: ['tiktok'], + forbiddenAfterRewrite: ['analytics.tiktok.com'], + }, + { + name: 'Microsoft Clarity', + url: 'https://www.clarity.ms/tag/XXXXXXX', + registryKey: 'clarity', + expectedPatterns: ['clarity'], + forbiddenAfterRewrite: ['www.clarity.ms'], + }, + { + name: 'Hotjar', + url: 'https://static.hotjar.com/c/hotjar-XXXXXXX.js?sv=7', + registryKey: 'hotjar', + expectedPatterns: ['hotjar'], + forbiddenAfterRewrite: ['static.hotjar.com', 'vars.hotjar.com'], + }, + { + name: 'Segment Analytics.js', + url: 'https://cdn.segment.com/analytics.js/v1/XXXXXXX/analytics.min.js', + registryKey: 'segment', + expectedPatterns: ['segment'], + forbiddenAfterRewrite: ['api.segment.io', 'cdn.segment.com'], + }, +] + +describe('third-party script proxy replacements', () => { + const proxyConfigs = getAllProxyConfigs(COLLECT_PREFIX) + + describe.each(testCases)('$name', ({ name, url, registryKey, expectedPatterns, forbiddenAfterRewrite }) => { + const proxyConfig = proxyConfigs[registryKey] + + it('has proxy config defined', () => { + expect(proxyConfig, `Missing proxy config for ${registryKey}`).toBeDefined() + expect(proxyConfig.rewrite, `Missing rewrite rules for ${registryKey}`).toBeDefined() + expect(proxyConfig.rewrite!.length, `Empty rewrite rules for ${registryKey}`).toBeGreaterThan(0) + }) + + it('downloads and rewrites script correctly', async () => { + let content: string + try { + content = await $fetch(url, { + responseType: 'text', + timeout: 10000, + }) + } + catch (e: any) { + // Some scripts may require valid IDs or have rate limiting + // Skip if we can't download + console.warn(`Could not download ${name}: ${e.message}`) + return + } + + expect(content.length, `Downloaded content for ${name} is empty`).toBeGreaterThan(0) + + // Check that at least some expected patterns exist before rewrite + const hasExpectedPatterns = expectedPatterns.some(pattern => + content.toLowerCase().includes(pattern.toLowerCase()), + ) + + if (!hasExpectedPatterns) { + console.warn(`Warning: ${name} script doesn't contain expected patterns. Script may have changed.`) + } + + // Apply rewrites + const rewritten = rewriteScriptUrls(content, proxyConfig.rewrite!) + + // Check that forbidden domains are replaced + for (const forbidden of forbiddenAfterRewrite) { + // Check for various URL formats + const patterns = [ + `"https://${forbidden}`, + `'https://${forbidden}`, + `\`https://${forbidden}`, + `"http://${forbidden}`, + `'http://${forbidden}`, + `"//${forbidden}`, + `'//${forbidden}`, + ] + + for (const pattern of patterns) { + expect( + rewritten.includes(pattern), + `Found unrewritten URL pattern "${pattern}" in ${name}`, + ).toBe(false) + } + } + + // Check that proxy paths were inserted + const hasProxyPaths = rewritten.includes(COLLECT_PREFIX) + if (hasExpectedPatterns) { + expect(hasProxyPaths, `No proxy paths found in rewritten ${name}`).toBe(true) + } + }, 15000) + }) + + describe('rewrite completeness', () => { + it('all proxy configs have matching route rules', () => { + for (const [key, config] of Object.entries(proxyConfigs)) { + expect(config.routes, `${key} missing routes`).toBeDefined() + expect(Object.keys(config.routes!).length, `${key} has empty routes`).toBeGreaterThan(0) + + // Each rewrite target should have a corresponding route + for (const rewrite of config.rewrite || []) { + const hasMatchingRoute = Object.keys(config.routes!).some((route) => { + const routeBase = route.replace('/**', '') + return rewrite.to.startsWith(routeBase.replace('/_scripts/c', COLLECT_PREFIX)) + }) + expect( + hasMatchingRoute, + `${key}: rewrite target "${rewrite.to}" has no matching route`, + ).toBe(true) + } + } + }) + }) + + describe('synthetic URL rewrite tests', () => { + // Test with synthetic script content to ensure all patterns work + const syntheticScripts: Record = { + googleAnalytics: ` + (function() { + // Modern GA4 endpoint + var ga = "https://www.google.com/g/collect"; + // Legacy endpoints + var ga2 = 'https://www.google-analytics.com/collect'; + fetch("//analytics.google.com/analytics.js"); + })(); + `, + googleTagManager: ` + (function() { + var gtm = "https://www.googletagmanager.com/gtm.js"; + iframe.src = 'https://www.googletagmanager.com/ns.html'; + })(); + `, + metaPixel: ` + !function(f,b,e,v,n,t,s) { + n.src='https://connect.facebook.net/en_US/fbevents.js'; + t.src="https://www.facebook.com/tr?id=123"; + }(); + `, + tiktokPixel: ` + (function() { + var url = "https://analytics.tiktok.com/i18n/pixel/events.js"; + })(); + `, + clarity: ` + (function(c,l,a,r,i,t,y) { + c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)}; + t.src="https://www.clarity.ms/tag/"+i; + })(); + `, + hotjar: ` + (function(h,o,t,j,a,r) { + h.hj=h.hj||function(){(h.hj.q=h.hj.q||[]).push(arguments)}; + a.src='https://static.hotjar.com/c/hotjar-'+j+'.js?sv='+a.sv; + r.src="https://vars.hotjar.com/vars/123.js"; + })(); + `, + segment: ` + (function() { + analytics.load = function(key) { + var script = document.createElement("script"); + script.src = "https://cdn.segment.com/analytics.js/v1/" + key + "/analytics.min.js"; + var apiHost = "https://api.segment.io/v1"; + }; + })(); + `, + } + + it.each(Object.entries(syntheticScripts))('%s synthetic script rewrites correctly', (key, content) => { + const config = proxyConfigs[key] + expect(config, `Missing config for ${key}`).toBeDefined() + + const rewritten = rewriteScriptUrls(content, config.rewrite!) + + // Should have proxy paths + expect(rewritten).toContain(COLLECT_PREFIX) + + // Should not have original domains in quoted strings + for (const { from } of config.rewrite!) { + expect(rewritten).not.toContain(`"https://${from}`) + expect(rewritten).not.toContain(`'https://${from}`) + expect(rewritten).not.toContain(`"//${from}`) + expect(rewritten).not.toContain(`'//${from}`) + } + }) + }) +}) From fc4a36279083bb9383c326a5bdfd19d1a167691b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Fri, 16 Jan 2026 15:09:06 +1100 Subject: [PATCH 3/5] feat(first-party): improve DX for v1 release - Default firstParty to true (graceful degradation for static) - Add /_scripts/status.json and /_scripts/health.json dev endpoints - Add DevTools First-Party tab with status, routes, and badges - Add CLI commands: status, clear, health - Add dev startup logging for proxy routes - Improve static preset error messages with actionable guidance - Expand documentation: - Platform rewrites (Vercel, Netlify, Cloudflare) - Architecture diagram - Troubleshooting section - FAQ section - Hybrid rendering (ISR, edge, route-level SSR) - Consent integration examples - Health check verification - Add first-party unit tests Co-Authored-By: Claude Opus 4.5 --- build.config.ts | 1 + client/app.vue | 166 ++++++- docs/content/docs/1.guides/2.first-party.md | 488 ++++++++++++++++++++ package.json | 4 + src/cli.ts | 165 +++++++ src/module.ts | 88 +++- src/runtime/server/api/scripts-health.ts | 91 ++++ src/runtime/server/api/scripts-status.ts | 27 ++ test/unit/first-party.test.ts | 111 +++++ 9 files changed, 1130 insertions(+), 11 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/runtime/server/api/scripts-health.ts create mode 100644 src/runtime/server/api/scripts-status.ts create mode 100644 test/unit/first-party.test.ts diff --git a/build.config.ts b/build.config.ts index 62955942..be8b75e0 100644 --- a/build.config.ts +++ b/build.config.ts @@ -3,5 +3,6 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ entries: [ './src/registry', + { input: './src/cli', builder: 'rollup' }, ], }) diff --git a/client/app.vue b/client/app.vue index 05934a78..98877dee 100644 --- a/client/app.vue +++ b/client/app.vue @@ -173,16 +173,29 @@ function titleToCamelCase(s: string) { } const version = ref(null) +const firstPartyStatus = ref<{ + enabled: boolean + scripts: string[] + routes: Record + collectPrefix: string +} | null>(null) + onDevtoolsClientConnected(async (client) => { devtools.value = client.devtools client.host.nuxt.hooks.hook('scripts:updated', (ctx) => { syncScripts(ctx.scripts) }) version.value = client.host.nuxt.$config.public['nuxt-scripts'].version + firstPartyStatus.value = client.host.nuxt.$config.public['nuxt-scripts-status'] || null syncScripts(client.host.nuxt._scripts || {}) }) const tab = ref('scripts') +function isFirstPartyScript(registryKey: string | undefined): boolean { + if (!registryKey || !firstPartyStatus.value?.enabled) return false + return firstPartyStatus.value.scripts.includes(registryKey) +} + function viewDocs(docs: string) { tab.value = 'docs' setTimeout(() => { @@ -268,7 +281,7 @@ function viewDocs(docs: string) { class="n-select-tabs flex flex-inline flex-wrap items-center border n-border-base rounded-lg n-bg-base" >