Skip to content

Commit 2bbeb68

Browse files
authored
fix: respect proxying self-hosted domains (#718)
1 parent 8a81779 commit 2bbeb68

8 files changed

Lines changed: 174 additions & 9 deletions

File tree

‎docs/content/scripts/umami-analytics.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,17 @@ links:
1818

1919
### Self-hosted Umami
2020

21-
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.
21+
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.
2222

2323
```ts
2424
useScriptUmamiAnalytics({
25-
scriptInput: {
26-
src: 'https://my-self-hosted/script.js'
27-
}
25+
websiteId: 'YOUR_WEBSITE_ID',
26+
hostUrl: 'https://my-self-hosted'
2827
})
2928
```
3029

30+
Use `scriptInput.src` only if you also need to override the script URL itself.
31+
3132
## Advanced Features
3233

3334
### Session Identification

‎packages/script/src/module.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,51 @@ export function applyAutoInject(
235235
rtEntry[autoInject.configField]=value
236236
}
237237

238+
constABSOLUTE_URL_RE=/^[a-z][a-z\d+.-]*:\/\//i
239+
240+
functionresolveConfiguredProxyDomain(value: unknown): string|undefined{
241+
if(typeofvalue!=='string')
242+
return
243+
244+
consttrimmed=value.trim()
245+
if(!trimmed||(!ABSOLUTE_URL_RE.test(trimmed)&&!trimmed.startsWith('//')))
246+
return
247+
248+
try{
249+
returnnewURL(trimmed,'https://nuxt-scripts.local').hostname||undefined
250+
}
251+
catch{
252+
253+
}
254+
}
255+
256+
exportfunctionresolveConfiguredProxyDomains(
257+
config: Record<string,any>|undefined,
258+
proxyConfig?: Pick<ProxyConfig,'autoInject'|'configDomainFields'>,
259+
): string[]{
260+
if(!config||typeofconfig!=='object')
261+
return[]
262+
263+
constdomains=newSet<string>()
264+
constscriptSrc=resolveConfiguredProxyDomain(config.scriptInput?.src)
265+
if(scriptSrc)
266+
domains.add(scriptSrc)
267+
268+
if(proxyConfig?.autoInject?.configField){
269+
constendpointDomain=resolveConfiguredProxyDomain(config[proxyConfig.autoInject.configField])
270+
if(endpointDomain)
271+
domains.add(endpointDomain)
272+
}
273+
274+
for(constfieldofproxyConfig?.configDomainFields||[]){
275+
constdomain=resolveConfiguredProxyDomain(config[field])
276+
if(domain)
277+
domains.add(domain)
278+
}
279+
280+
return[...domains].sort()
281+
}
282+
238283
exportinterfaceModuleOptions{
239284
/**
240285
* Base path prefix for all script endpoints (proxy and bundled assets).
@@ -714,6 +759,7 @@ export default defineNuxtModule<ModuleOptions>({
714759
constunmatchedScripts: string[]=[]
715760
lettotalDomains=0
716761
constdevtoolsScripts: ProxyDevtoolsScript[]=[]
762+
constpublicScripts=nuxt.options.runtimeConfig.public?.scriptsasRecord<string,any>|undefined
717763

718764
for(constkeyofregistryKeys){
719765
constscript=scriptByKey.get(key)
@@ -736,12 +782,22 @@ export default defineNuxtModule<ModuleOptions>({
736782
// Per-script privacy override from user config (stays on input after normalization)
737783
constentry=(config.registryasRecord<string,any>)?.[key]
738784
constinputPrivacy=entry?.[0]?.privacy
785+
construntimeEntry=publicScripts?.[key]&&typeofpublicScripts[key]==='object'
786+
? publicScripts[key]
787+
: undefined
739788

740789
for(constdomainofproxyConfig.domains){
741790
domainPrivacy[domain]=inputPrivacy??proxyConfig.privacy
742791
totalDomains++
743792
}
744793

794+
for(constsourceof[entry?.[0],runtimeEntry]){
795+
for(constdomainofresolveConfiguredProxyDomains(source,proxyConfig)){
796+
domainPrivacy[domain]=inputPrivacy??proxyConfig.privacy
797+
totalDomains++
798+
}
799+
}
800+
745801
if(proxyConfig.autoInject&&config.registry)
746802
applyAutoInject(config.registry,nuxt.options.runtimeConfig,proxyPrefix,key,proxyConfig.autoInject)
747803

‎packages/script/src/registry.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
308308
proxy: {
309309
domains: ['va.vercel-scripts.com','vitals.vercel-insights.com'],
310310
privacy: PRIVACY_IP_ONLY,
311+
configDomainFields: ['endpoint'],
311312
},
312313
}),
313314
def('posthog',{
@@ -352,6 +353,7 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
352353
proxy: {
353354
domains: ['cdn.matomo.cloud'],
354355
privacy: PRIVACY_IP_ONLY,
356+
configDomainFields: ['matomoUrl','trackerUrl'],
355357
},
356358
partytown: {forwards: ['_paq.push']},
357359
}),
@@ -387,6 +389,7 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
387389
domains: ['cdn.databuddy.cc','basket.databuddy.cc'],
388390
privacy: PRIVACY_IP_ONLY,
389391
autoInject: {field: 'apiUrl',target: 'basket.databuddy.cc'},
392+
configDomainFields: ['scriptUrl'],
390393
},
391394
}),
392395
def('segment',{
@@ -860,6 +863,7 @@ export function buildProxyConfigsFromRegistry(
860863
domains: proxyDef.domains.map(d=>typeofd==='string' ? d : d.domain),
861864
privacy: proxyDef.privacy||{ip: false,userAgent: false,language: false,screen: false,timezone: false,hardware: false},
862865
autoInject: proxyDef.autoInject ? resolveAutoInject(proxyDef.autoInject) : undefined,
866+
configDomainFields: proxyDef.configDomainFields,
863867
sdkPatches: proxyDef.sdkPatches,
864868
}
865869
}

‎packages/script/src/runtime/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,8 @@ export interface ProxyCapability {
395395
privacy: import('../runtime/server/utils/privacy').ProxyPrivacyInput
396396
/** Auto-inject proxy endpoint into the script's SDK config. */
397397
autoInject?: ProxyAutoInject
398+
/** Config fields that may contain custom external hosts or endpoints. */
399+
configDomainFields?: string[]
398400
/** AST-level SDK patches applied during URL rewriting. */
399401
sdkPatches?: SdkPatch[]
400402
}
@@ -540,6 +542,8 @@ export interface ProxyConfig {
540542
privacy: ProxyPrivacyInput
541543
/** Auto-inject proxy endpoint config into the script's SDK options (resolved form) */
542544
autoInject?: ResolvedProxyAutoInject
545+
/** Config fields that may contain custom external hosts or endpoints. */
546+
configDomainFields?: string[]
543547
/** AST-level SDK patches applied during URL rewriting. */
544548
sdkPatches?: SdkPatch[]
545549
}

‎playground/pages/third-parties/umami-analytics.vue‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ useHead({
88
// Umami Analytics with custom configuration
99
const { proxy, status } =useScriptUmamiAnalytics({
1010
websiteId: 'demo-website-id-123',
11-
hostUrl: 'https://analytics.example.com', //Optional: custom Umami instance
11+
hostUrl: 'https://analytics.example.com', //Self-hosted Umami origin
1212
autoTrack: true,
1313
domains: ['localhost', 'example.com'],
1414
tag: 'playground',
@@ -149,7 +149,7 @@ function trackUserFlow() {
149149
<li>Auto-tracking enabled for page views</li>
150150
<li>Domain filtering: localhost, example.com</li>
151151
<li>Tagged as: playground</li>
152-
<li>Custom host URL configured</li>
152+
<li>Self-hosted Umami origin configured via <code>hostUrl</code></li>
153153
<li>Privacy-focused (no cookies, no personal data)</li>
154154
</ul>
155155
</div>
@@ -276,7 +276,7 @@ function trackUserFlow() {
276276
</h3>
277277
<preclass="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs overflow-x-auto"><code>const { proxy, status } = useScriptUmamiAnalytics({
278278
websiteId: 'your-website-id',
279-
hostUrl: 'https://your-umami-instance.com', // optional
279+
hostUrl: 'https://your-umami-instance.com', // self-hosted origin
280280
autoTrack: true,
281281
domains: ['yourdomain.com'],
282282
tag: 'environment-tag',
@@ -313,7 +313,7 @@ proxy.identify({ user_id: '123', subscription: 'premium' })</code></pre>
313313
<li>No personal data collection</li>
314314
<li>GDPR compliant by design</li>
315315
<li>Lightweight script (&lt; 2KB)</li>
316-
<li>Self-hosted option available</li>
316+
<li>Self-hosted instances configured with <code>hostUrl</code></li>
317317
</ul>
318318
</div>
319319
</div>

‎test/unit/first-party.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
importtype{RegistryScript}from'../../packages/script/src/runtime/types'
22
import{describe,expect,it}from'vitest'
3+
import{resolveConfiguredProxyDomains}from'../../packages/script/src/module'
34
import{buildProxyConfigsFromRegistry,registry}from'../../packages/script/src/registry'
45
import{
56
anonymizeIP,
@@ -165,6 +166,38 @@ describe('first-party mode', () => {
165166
expect(configs.metaPixel.autoInject).toBeUndefined()
166167
expect(configs.clarity.autoInject).toBeUndefined()
167168
})
169+
170+
it('derives extra allowlist domains for self-hosted Umami',async()=>{
171+
constconfigs=awaitgetProxyConfigs()
172+
expect(resolveConfiguredProxyDomains({
173+
scriptInput: {
174+
src: 'https://analytics.example.com/script.js',
175+
},
176+
},configs.umamiAnalytics)).toEqual(['analytics.example.com'])
177+
})
178+
179+
it('derives extra allowlist domains for custom Matomo hosts',async()=>{
180+
constconfigs=awaitgetProxyConfigs()
181+
expect(resolveConfiguredProxyDomains({
182+
matomoUrl: 'https://analytics.example.com',
183+
trackerUrl: 'https://analytics.example.com/matomo.php',
184+
},configs.matomoAnalytics)).toEqual(['analytics.example.com'])
185+
})
186+
187+
it('derives extra allowlist domains for self-hosted Vercel Analytics',async()=>{
188+
constconfigs=awaitgetProxyConfigs()
189+
expect(resolveConfiguredProxyDomains({
190+
endpoint: 'https://analytics.example.com/v1/vitals',
191+
},configs.vercelAnalytics)).toEqual(['analytics.example.com'])
192+
})
193+
194+
it('derives extra allowlist domains for custom Databuddy script hosts',async()=>{
195+
constconfigs=awaitgetProxyConfigs()
196+
expect(resolveConfiguredProxyDomains({
197+
scriptUrl: 'https://cdn.analytics.example.com/databuddy.js',
198+
apiUrl: 'https://events.analytics.example.com',
199+
},configs.databuddyAnalytics)).toEqual(['cdn.analytics.example.com','events.analytics.example.com'])
200+
})
168201
})
169202

170203
describe('full chain: capabilities → proxy config → domains',()=>{

‎test/unit/proxy-configs.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,13 @@ describe('proxy configs', () => {
340340
expect(config?.domains).toContain('plausible.io')
341341
})
342342

343+
it('returns config domain fields for providers with custom hosts',async()=>{
344+
constconfigs=awaitgetProxyConfigs()
345+
expect(configs.matomoAnalytics?.configDomainFields).toEqual(['matomoUrl','trackerUrl'])
346+
expect(configs.vercelAnalytics?.configDomainFields).toEqual(['endpoint'])
347+
expect(configs.databuddyAnalytics?.configDomainFields).toEqual(['scriptUrl'])
348+
})
349+
343350
it('returns proxy config for cloudflareWebAnalytics',async()=>{
344351
constconfig=(awaitgetProxyConfigs()).cloudflareWebAnalytics
345352
expect(config).toBeDefined()

‎test/unit/setup.test.ts‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
importtype{NuxtConfigScriptRegistry}from'../../packages/script/src/runtime/types'
22
import{describe,expect,it}from'vitest'
3-
import{applyAutoInject}from'../../packages/script/src/module'
3+
import{applyAutoInject,resolveConfiguredProxyDomains}from'../../packages/script/src/module'
44

55
describe('applyAutoInject',()=>{
66
constposthogAutoInject={
@@ -89,3 +89,63 @@ describe('applyAutoInject', () => {
8989
expect((registryasany).posthog[0].apiHost).toBe('/_analytics/us.i.posthog.com')
9090
})
9191
})
92+
93+
describe('resolveConfiguredProxyDomains',()=>{
94+
constumamiProxyConfig={
95+
autoInject: {
96+
configField: 'hostUrl',
97+
computeValue: (proxyPrefix: string)=>`${proxyPrefix}/cloud.umami.is`,
98+
},
99+
}
100+
101+
it('includes the hostname from a custom scriptInput src',()=>{
102+
expect(resolveConfiguredProxyDomains({
103+
scriptInput: {
104+
src: 'https://analytics.example.com/script.js',
105+
},
106+
},umamiProxyConfig)).toEqual(['analytics.example.com'])
107+
})
108+
109+
it('includes the hostname from an explicit endpoint field',()=>{
110+
expect(resolveConfiguredProxyDomains({
111+
hostUrl: 'https://analytics.example.com',
112+
},umamiProxyConfig)).toEqual(['analytics.example.com'])
113+
})
114+
115+
it('ignores relative proxy paths injected by the module',()=>{
116+
expect(resolveConfiguredProxyDomains({
117+
hostUrl: '/_scripts/p/cloud.umami.is',
118+
},umamiProxyConfig)).toEqual([])
119+
})
120+
121+
it('deduplicates equivalent domains',()=>{
122+
expect(resolveConfiguredProxyDomains({
123+
hostUrl: 'https://analytics.example.com',
124+
scriptInput: {
125+
src: 'https://analytics.example.com/script.js',
126+
},
127+
},umamiProxyConfig)).toEqual(['analytics.example.com'])
128+
})
129+
130+
it('includes additional registry-declared config domain fields',()=>{
131+
expect(resolveConfiguredProxyDomains({
132+
trackerUrl: 'https://analytics.example.com/matomo.php',
133+
matomoUrl: 'https://analytics.example.com',
134+
},{
135+
configDomainFields: ['matomoUrl','trackerUrl'],
136+
})).toEqual(['analytics.example.com'])
137+
})
138+
139+
it('handles multiple custom hosts across script and endpoint fields',()=>{
140+
expect(resolveConfiguredProxyDomains({
141+
scriptUrl: 'https://cdn.analytics.example.com/databuddy.js',
142+
apiUrl: 'https://events.analytics.example.com',
143+
},{
144+
autoInject: {
145+
configField: 'apiUrl',
146+
computeValue: (proxyPrefix: string)=>`${proxyPrefix}/basket.databuddy.cc`,
147+
},
148+
configDomainFields: ['scriptUrl'],
149+
})).toEqual(['cdn.analytics.example.com','events.analytics.example.com'])
150+
})
151+
})

0 commit comments

Comments
 (0)