diff --git a/docs/content/scripts/umami-analytics.md b/docs/content/scripts/umami-analytics.md index 7f0989fcb..75a204830 100644 --- a/docs/content/scripts/umami-analytics.md +++ b/docs/content/scripts/umami-analytics.md @@ -18,16 +18,17 @@ links: ### Self-hosted Umami -If you use a self-hosted version of Umami, provide an explicit src for the script so that the browser sends API events to the correct endpoint. +If you use a self-hosted version of Umami, set `hostUrl` to your Umami origin. This is the provider-specific way to tell Umami where to send events. ```ts useScriptUmamiAnalytics({ - scriptInput: { - src: 'https://my-self-hosted/script.js' - } + websiteId: 'YOUR_WEBSITE_ID', + hostUrl: 'https://my-self-hosted' }) ``` +Use `scriptInput.src` only if you also need to override the script URL itself. + ## Advanced Features ### Session Identification diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 6bf2deee7..752c3a903 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -235,6 +235,51 @@ export function applyAutoInject( rtEntry[autoInject.configField] = value } +const ABSOLUTE_URL_RE = /^[a-z][a-z\d+.-]*:\/\//i + +function resolveConfiguredProxyDomain(value: unknown): string | undefined { + if (typeof value !== 'string') + return + + const trimmed = value.trim() + if (!trimmed || (!ABSOLUTE_URL_RE.test(trimmed) && !trimmed.startsWith('//'))) + return + + try { + return new URL(trimmed, 'https://nuxt-scripts.local').hostname || undefined + } + catch { + + } +} + +export function resolveConfiguredProxyDomains( + config: Record | undefined, + proxyConfig?: Pick, +): string[] { + if (!config || typeof config !== 'object') + return [] + + const domains = new Set() + const scriptSrc = resolveConfiguredProxyDomain(config.scriptInput?.src) + if (scriptSrc) + domains.add(scriptSrc) + + if (proxyConfig?.autoInject?.configField) { + const endpointDomain = resolveConfiguredProxyDomain(config[proxyConfig.autoInject.configField]) + if (endpointDomain) + domains.add(endpointDomain) + } + + for (const field of proxyConfig?.configDomainFields || []) { + const domain = resolveConfiguredProxyDomain(config[field]) + if (domain) + domains.add(domain) + } + + return [...domains].sort() +} + export interface ModuleOptions { /** * Base path prefix for all script endpoints (proxy and bundled assets). @@ -714,6 +759,7 @@ export default defineNuxtModule({ const unmatchedScripts: string[] = [] let totalDomains = 0 const devtoolsScripts: ProxyDevtoolsScript[] = [] + const publicScripts = nuxt.options.runtimeConfig.public?.scripts as Record | undefined for (const key of registryKeys) { const script = scriptByKey.get(key) @@ -736,12 +782,22 @@ export default defineNuxtModule({ // Per-script privacy override from user config (stays on input after normalization) const entry = (config.registry as Record)?.[key] const inputPrivacy = entry?.[0]?.privacy + const runtimeEntry = publicScripts?.[key] && typeof publicScripts[key] === 'object' + ? publicScripts[key] + : undefined for (const domain of proxyConfig.domains) { domainPrivacy[domain] = inputPrivacy ?? proxyConfig.privacy totalDomains++ } + for (const source of [entry?.[0], runtimeEntry]) { + for (const domain of resolveConfiguredProxyDomains(source, proxyConfig)) { + domainPrivacy[domain] = inputPrivacy ?? proxyConfig.privacy + totalDomains++ + } + } + if (proxyConfig.autoInject && config.registry) applyAutoInject(config.registry, nuxt.options.runtimeConfig, proxyPrefix, key, proxyConfig.autoInject) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 7168deb1e..37bc14a56 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -308,6 +308,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro proxy: { domains: ['va.vercel-scripts.com', 'vitals.vercel-insights.com'], privacy: PRIVACY_IP_ONLY, + configDomainFields: ['endpoint'], }, }), def('posthog', { @@ -352,6 +353,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro proxy: { domains: ['cdn.matomo.cloud'], privacy: PRIVACY_IP_ONLY, + configDomainFields: ['matomoUrl', 'trackerUrl'], }, partytown: { forwards: ['_paq.push'] }, }), @@ -387,6 +389,7 @@ export async function registry(resolve?: (path: string) => Promise): Pro domains: ['cdn.databuddy.cc', 'basket.databuddy.cc'], privacy: PRIVACY_IP_ONLY, autoInject: { field: 'apiUrl', target: 'basket.databuddy.cc' }, + configDomainFields: ['scriptUrl'], }, }), def('segment', { @@ -860,6 +863,7 @@ export function buildProxyConfigsFromRegistry( domains: proxyDef.domains.map(d => typeof d === 'string' ? d : d.domain), privacy: proxyDef.privacy || { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, autoInject: proxyDef.autoInject ? resolveAutoInject(proxyDef.autoInject) : undefined, + configDomainFields: proxyDef.configDomainFields, sdkPatches: proxyDef.sdkPatches, } } diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 5ee520415..12fc5f8d7 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -395,6 +395,8 @@ export interface ProxyCapability { privacy: import('../runtime/server/utils/privacy').ProxyPrivacyInput /** Auto-inject proxy endpoint into the script's SDK config. */ autoInject?: ProxyAutoInject + /** Config fields that may contain custom external hosts or endpoints. */ + configDomainFields?: string[] /** AST-level SDK patches applied during URL rewriting. */ sdkPatches?: SdkPatch[] } @@ -540,6 +542,8 @@ export interface ProxyConfig { privacy: ProxyPrivacyInput /** Auto-inject proxy endpoint config into the script's SDK options (resolved form) */ autoInject?: ResolvedProxyAutoInject + /** Config fields that may contain custom external hosts or endpoints. */ + configDomainFields?: string[] /** AST-level SDK patches applied during URL rewriting. */ sdkPatches?: SdkPatch[] } diff --git a/playground/pages/third-parties/umami-analytics.vue b/playground/pages/third-parties/umami-analytics.vue index 434431d26..b642cd134 100644 --- a/playground/pages/third-parties/umami-analytics.vue +++ b/playground/pages/third-parties/umami-analytics.vue @@ -8,7 +8,7 @@ useHead({ // Umami Analytics with custom configuration const { proxy, status } = useScriptUmamiAnalytics({ websiteId: 'demo-website-id-123', - hostUrl: 'https://analytics.example.com', // Optional: custom Umami instance + hostUrl: 'https://analytics.example.com', // Self-hosted Umami origin autoTrack: true, domains: ['localhost', 'example.com'], tag: 'playground', @@ -149,7 +149,7 @@ function trackUserFlow() {
  • Auto-tracking enabled for page views
  • Domain filtering: localhost, example.com
  • Tagged as: playground
  • -
  • Custom host URL configured
  • +
  • Self-hosted Umami origin configured via hostUrl
  • Privacy-focused (no cookies, no personal data)
  • @@ -276,7 +276,7 @@ function trackUserFlow() {
    const { proxy, status } = useScriptUmamiAnalytics({
       websiteId: 'your-website-id',
    -  hostUrl: 'https://your-umami-instance.com', // optional
    +  hostUrl: 'https://your-umami-instance.com', // self-hosted origin
       autoTrack: true,
       domains: ['yourdomain.com'],
       tag: 'environment-tag',
    @@ -313,7 +313,7 @@ proxy.identify({ user_id: '123', subscription: 'premium' })
  • No personal data collection
  • GDPR compliant by design
  • Lightweight script (< 2KB)
  • -
  • Self-hosted option available
  • +
  • Self-hosted instances configured with hostUrl
  • diff --git a/test/unit/first-party.test.ts b/test/unit/first-party.test.ts index 4ff5855eb..011cf3f6d 100644 --- a/test/unit/first-party.test.ts +++ b/test/unit/first-party.test.ts @@ -1,5 +1,6 @@ import type { RegistryScript } from '../../packages/script/src/runtime/types' import { describe, expect, it } from 'vitest' +import { resolveConfiguredProxyDomains } from '../../packages/script/src/module' import { buildProxyConfigsFromRegistry, registry } from '../../packages/script/src/registry' import { anonymizeIP, @@ -165,6 +166,38 @@ describe('first-party mode', () => { expect(configs.metaPixel.autoInject).toBeUndefined() expect(configs.clarity.autoInject).toBeUndefined() }) + + it('derives extra allowlist domains for self-hosted Umami', async () => { + const configs = await getProxyConfigs() + expect(resolveConfiguredProxyDomains({ + scriptInput: { + src: 'https://analytics.example.com/script.js', + }, + }, configs.umamiAnalytics)).toEqual(['analytics.example.com']) + }) + + it('derives extra allowlist domains for custom Matomo hosts', async () => { + const configs = await getProxyConfigs() + expect(resolveConfiguredProxyDomains({ + matomoUrl: 'https://analytics.example.com', + trackerUrl: 'https://analytics.example.com/matomo.php', + }, configs.matomoAnalytics)).toEqual(['analytics.example.com']) + }) + + it('derives extra allowlist domains for self-hosted Vercel Analytics', async () => { + const configs = await getProxyConfigs() + expect(resolveConfiguredProxyDomains({ + endpoint: 'https://analytics.example.com/v1/vitals', + }, configs.vercelAnalytics)).toEqual(['analytics.example.com']) + }) + + it('derives extra allowlist domains for custom Databuddy script hosts', async () => { + const configs = await getProxyConfigs() + expect(resolveConfiguredProxyDomains({ + scriptUrl: 'https://cdn.analytics.example.com/databuddy.js', + apiUrl: 'https://events.analytics.example.com', + }, configs.databuddyAnalytics)).toEqual(['cdn.analytics.example.com', 'events.analytics.example.com']) + }) }) describe('full chain: capabilities → proxy config → domains', () => { diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 26b3e3621..01cdc2d61 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -340,6 +340,13 @@ describe('proxy configs', () => { expect(config?.domains).toContain('plausible.io') }) + it('returns config domain fields for providers with custom hosts', async () => { + const configs = await getProxyConfigs() + expect(configs.matomoAnalytics?.configDomainFields).toEqual(['matomoUrl', 'trackerUrl']) + expect(configs.vercelAnalytics?.configDomainFields).toEqual(['endpoint']) + expect(configs.databuddyAnalytics?.configDomainFields).toEqual(['scriptUrl']) + }) + it('returns proxy config for cloudflareWebAnalytics', async () => { const config = (await getProxyConfigs()).cloudflareWebAnalytics expect(config).toBeDefined() diff --git a/test/unit/setup.test.ts b/test/unit/setup.test.ts index 06a9c5d70..454fed61a 100644 --- a/test/unit/setup.test.ts +++ b/test/unit/setup.test.ts @@ -1,6 +1,6 @@ import type { NuxtConfigScriptRegistry } from '../../packages/script/src/runtime/types' import { describe, expect, it } from 'vitest' -import { applyAutoInject } from '../../packages/script/src/module' +import { applyAutoInject, resolveConfiguredProxyDomains } from '../../packages/script/src/module' describe('applyAutoInject', () => { const posthogAutoInject = { @@ -89,3 +89,63 @@ describe('applyAutoInject', () => { expect((registry as any).posthog[0].apiHost).toBe('/_analytics/us.i.posthog.com') }) }) + +describe('resolveConfiguredProxyDomains', () => { + const umamiProxyConfig = { + autoInject: { + configField: 'hostUrl', + computeValue: (proxyPrefix: string) => `${proxyPrefix}/cloud.umami.is`, + }, + } + + it('includes the hostname from a custom scriptInput src', () => { + expect(resolveConfiguredProxyDomains({ + scriptInput: { + src: 'https://analytics.example.com/script.js', + }, + }, umamiProxyConfig)).toEqual(['analytics.example.com']) + }) + + it('includes the hostname from an explicit endpoint field', () => { + expect(resolveConfiguredProxyDomains({ + hostUrl: 'https://analytics.example.com', + }, umamiProxyConfig)).toEqual(['analytics.example.com']) + }) + + it('ignores relative proxy paths injected by the module', () => { + expect(resolveConfiguredProxyDomains({ + hostUrl: '/_scripts/p/cloud.umami.is', + }, umamiProxyConfig)).toEqual([]) + }) + + it('deduplicates equivalent domains', () => { + expect(resolveConfiguredProxyDomains({ + hostUrl: 'https://analytics.example.com', + scriptInput: { + src: 'https://analytics.example.com/script.js', + }, + }, umamiProxyConfig)).toEqual(['analytics.example.com']) + }) + + it('includes additional registry-declared config domain fields', () => { + expect(resolveConfiguredProxyDomains({ + trackerUrl: 'https://analytics.example.com/matomo.php', + matomoUrl: 'https://analytics.example.com', + }, { + configDomainFields: ['matomoUrl', 'trackerUrl'], + })).toEqual(['analytics.example.com']) + }) + + it('handles multiple custom hosts across script and endpoint fields', () => { + expect(resolveConfiguredProxyDomains({ + scriptUrl: 'https://cdn.analytics.example.com/databuddy.js', + apiUrl: 'https://events.analytics.example.com', + }, { + autoInject: { + configField: 'apiUrl', + computeValue: (proxyPrefix: string) => `${proxyPrefix}/basket.databuddy.cc`, + }, + configDomainFields: ['scriptUrl'], + })).toEqual(['cdn.analytics.example.com', 'events.analytics.example.com']) + }) +})