Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/content/docs/1.guides/2.first-party.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,26 @@ Aliases change only the hostname segment. Set the top-level `prefix` option, for
Aliases keep the real hostname out of request **URLs**. If an SDK builds its collection URL at runtime, the host may still appear in the client JavaScript. Aliasing changes the network-visible path; it does not obfuscate your bundle.
::

## Separate Proxy Origin

By default, client-facing proxy URLs are relative to the page origin. Set `proxy.origin` to send them to another host instead, for example a subdomain that points at the same deployment:

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
proxy: {
origin: 'https://t.example.com',
}
}
})
```

Build-time URL rewrites, auto-injected endpoints, runtime-intercepted requests and Partytown worker requests then use `https://t.example.com/_scripts/p/...`. The server route, bundled script assets and the server-side proxy config keep their relative paths, so that origin must serve this Nuxt app's `/_scripts/p/**` route.

::callout{type="warning"}
Requests to the proxy origin are cross-origin. SDKs that read collection responses need CORS headers from that origin.
::

## Opting Out

### Per-Script
Expand Down
17 changes: 17 additions & 0 deletions docs/content/docs/3.api/5.nuxt-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,23 @@ export default defineNuxtConfig({
})
```

## `proxy.origin`{lang="ts"}

- Type: `string`{lang="ts"}
- Default: `undefined`

Origin for client-facing proxy URLs: the runtime intercept plugin, build-time bundle rewrites, auto-injected registry endpoints and the Partytown `resolveUrl`. Server routes and bundled assets keep their relative paths, so the origin must route `/_scripts/p/**` (or your `prefix`) to this Nuxt server.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
proxy: {
origin: 'https://t.example.com', // https://t.example.com/_scripts/p/...
}
}
})
```

## Partytown (Web Worker) :badge[Experimental]{color="amber"}

Load individual scripts in a web worker using [Partytown](https://partytown.qwik.dev/). A registry `trigger` is still required to generate a global call, but it does not defer the Partytown tag: the current implementation writes that tag into the server-rendered HTML.
Expand Down
18 changes: 13 additions & 5 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,13 @@ export interface ModuleOptions {
* First-party proxy behaviour.
*/
proxy?: {
/**
* Origin for client-facing proxy URLs (intercept plugin, bundle rewrites, registry endpoints).
* Server routes and bundled assets keep their relative paths.
*
* @example 'https://t.example.com'
*/
origin?: string
/**
* Replace third-party hostnames in proxy paths with aliases, so the real domain
* (e.g. a self-hosted analytics host) never appears in client-facing URLs.
Expand Down Expand Up @@ -535,6 +542,7 @@ export default defineNuxtModule<ModuleOptions>({
// domains at runtime, so it's safe to register even when no scripts use proxy.
const scriptsBase = config.prefix || '/_scripts'
const proxyPrefix = `${scriptsBase}/p`
const clientProxyPrefix = config.proxy?.origin ? new URL(proxyPrefix, config.proxy.origin).href : proxyPrefix
const assetsPrefix = `${scriptsBase}/assets`
const proxyConfigs: Partial<Record<RegistryScriptKey, ProxyConfig>> = {}

Expand Down Expand Up @@ -852,7 +860,7 @@ export default defineNuxtModule<ModuleOptions>({
}

if (proxyConfig.autoInject && config.registry)
applyAutoInject(config.registry, nuxt.options.runtimeConfig, proxyPrefix, key, proxyConfig.autoInject, proxyAlias)
applyAutoInject(config.registry, nuxt.options.runtimeConfig, clientProxyPrefix, key, proxyConfig.autoInject, proxyAlias)

if (nuxt.options.dev)
devtoolsScripts.push(buildDevtoolsEntry(key, script, configKey, proxyConfig))
Expand Down Expand Up @@ -892,15 +900,15 @@ export default defineNuxtModule<ModuleOptions>({
const aliasToDomain = invertAliasMap(domainAliases)
if (proxyConfiguredKeys.includes('googleAnalytics') && !isProxyDisabled('googleAnalytics', config.registry, nuxt.options.runtimeConfig)) {
nuxt.options.runtimeConfig.public['nuxt-scripts'] = defu(
{ proxyEndpoints: { googleAnalytics: aliasProxyValue(`${proxyPrefix}/www.google-analytics.com`, proxyPrefix, proxyAlias) } },
{ proxyEndpoints: { googleAnalytics: aliasProxyValue(`${clientProxyPrefix}/www.google-analytics.com`, clientProxyPrefix, proxyAlias) } },
nuxt.options.runtimeConfig.public['nuxt-scripts'] as any,
) as any
}

// Register intercept plugin
addPluginTemplate({
filename: 'nuxt-scripts-intercept.client.mjs',
getContents() { return generateInterceptPluginContents(proxyPrefix, { testMode: !!nuxt.options.test, domainAliases }) },
getContents() { return generateInterceptPluginContents(clientProxyPrefix, { testMode: !!nuxt.options.test, domainAliases }) },
})

// Server-side proxy config
Expand All @@ -926,7 +934,7 @@ export default defineNuxtModule<ModuleOptions>({
if (partytownScripts.size && hasNuxtModule('@nuxtjs/partytown')) {
const partytownConfig = (nuxt.options as any).partytown || {}
if (!partytownConfig.resolveUrl) {
partytownConfig.resolveUrl = generatePartytownResolveUrl(proxyPrefix, domainAliases, Object.keys(domainPrivacy))
partytownConfig.resolveUrl = generatePartytownResolveUrl(clientProxyPrefix, domainAliases, Object.keys(domainPrivacy))
;(nuxt.options as any).partytown = partytownConfig
logger.info('[partytown] Auto-configured resolveUrl for proxy')
}
Expand Down Expand Up @@ -955,7 +963,7 @@ export default defineNuxtModule<ModuleOptions>({
scripts: registryScriptsWithImport,
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
proxyConfigs,
proxyPrefix,
proxyPrefix: clientProxyPrefix,
domainAliases,
partytownScripts,
moduleDetected(module) {
Expand Down
4 changes: 3 additions & 1 deletion packages/script/src/plugins/intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ export default defineNuxtPlugin({
function proxyUrl(url) {
try {
const parsed = new URL(url, location.origin);
const proxyBase = new URL(proxyPrefix + '/', location.origin);
if (parsed.origin === proxyBase.origin && parsed.pathname.startsWith(proxyBase.pathname)) return url;
if ((parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.origin !== location.origin) {
const seg = domainAliases[parsed.host] || parsed.host;
return location.origin + proxyPrefix + '/' + seg + parsed.pathname + parsed.search;
return new URL(proxyPrefix + '/' + seg + parsed.pathname + parsed.search, location.origin).href;
}
} catch { /* Invalid URL inputs retain native behavior. */ }
return url;
Expand Down
24 changes: 15 additions & 9 deletions packages/script/src/plugins/rewrite-ast.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ProxyRewrite, SdkPatch } from '../runtime/types'
import MagicString from 'magic-string'
import { parseAndWalk, ScopeTracker, ScopeTrackerFunction, ScopeTrackerFunctionParam, ScopeTrackerIdentifier, ScopeTrackerVariable, walk } from 'oxc-walker'
import { joinURL, parseURL } from 'ufo'
import { hasProtocol, joinURL, parseURL } from 'ufo'

const WORD_OR_DOLLAR_RE = /[\w$]/
const PROTOCOL_PREFIX_RE = /^https?:$/
Expand Down Expand Up @@ -208,6 +208,12 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
return prev && WORD_OR_DOLLAR_RE.test(prev) ? ' ' : ''
}

// Rewrite targets are absolute when `proxy.origin` is set; only relative
// targets resolve against the page origin at runtime.
function originPrefix(target: string): string {
return hasProtocol(target) ? '' : 'self.location.origin+'
}

function sourceOf(node: any): string {
return content.slice(node.start, node.end)
}
Expand Down Expand Up @@ -404,14 +410,14 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
// which creates a malformed "https:http://localhost:3001/..." URL
const sibling = ctx.key === 'right' ? parent.left : parent.right
if (sibling?.type === 'Literal' && typeof sibling.value === 'string' && PROTOCOL_PREFIX_RE.test(sibling.value)) {
s.overwrite(parent.start, parent.end, `${needsLeadingSpace(parent.start)}self.location.origin+${quote}${rewritten}${quote}`)
s.overwrite(parent.start, parent.end, `${needsLeadingSpace(parent.start)}${originPrefix(rewritten)}${quote}${rewritten}${quote}`)
}
else {
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}self.location.origin+${quote}${rewritten}${quote}`)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${originPrefix(rewritten)}${quote}${rewritten}${quote}`)
}
}
else {
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}self.location.origin+${quote}${rewritten}${quote}`)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${originPrefix(rewritten)}${quote}${rewritten}${quote}`)
}
}

Expand All @@ -433,7 +439,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
s.overwrite(node.start, node.end, `\`${rewritten}\``)
}
else {
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}self.location.origin+\`${rewritten}\``)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${originPrefix(rewritten)}\`${rewritten}\``)
}
}
else if (expressions?.length > 0 && quasis?.length > 0) {
Expand All @@ -450,7 +456,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
// Overwrite just the first quasi and prepend self.location.origin+
// Template: `{quasi0}${expr0}{quasi1}...`
// We rewrite quasi0 content and add prefix before the opening backtick
s.overwrite(node.start, firstQuasi.end, `${needsLeadingSpace(node.start)}self.location.origin+\`${rewritten}`)
s.overwrite(node.start, firstQuasi.end, `${needsLeadingSpace(node.start)}${originPrefix(rewritten)}\`${rewritten}`)
}
}

Expand Down Expand Up @@ -574,7 +580,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
if (!rewrite)
continue
const proxyPath = patch.appendPath ? `${rewrite.to}/${patch.appendPath}` : rewrite.to
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}self.location.origin+"${proxyPath}"`)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${originPrefix(proxyPath)}"${proxyPath}"`)
}
}
}
Expand All @@ -598,7 +604,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
const rewrite = rewrites.find(r => r.from === patch.fromDomain)
if (!rewrite)
continue
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}(self.location.origin+"${rewrite.to}")`)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}(${originPrefix(rewrite.to)}"${rewrite.to}")`)
break
}
}
Expand Down Expand Up @@ -640,7 +646,7 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
const pathNode = findScriptLoaderPathExpression(urlArg, patch.pathPrefix)
if (!pathNode)
continue
s.overwrite(urlArg.start, urlArg.end, `${needsLeadingSpace(urlArg.start)}self.location.origin+"${rewrite.to}"+${sourceOf(pathNode)}`)
s.overwrite(urlArg.start, urlArg.end, `${needsLeadingSpace(urlArg.start)}${originPrefix(rewrite.to)}"${rewrite.to}"+${sourceOf(pathNode)}`)
// The replaced range may contain literals the domain-rewrite pass
// would also edit — descending would produce conflicting overwrites.
this.skip()
Expand Down
7 changes: 6 additions & 1 deletion test/e2e/proxy-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ describe('proxy path aliases', () => {
it('auto-injects the aliased endpoint instead of the real hostname', async () => {
// app.vue renders the resolved Plausible endpoint from public runtime config.
const html = await $fetch('/')
expect(html).toContain('/_scripts/p/pl/api/event')
expect(html).toContain('https://t.example.test/_scripts/p/pl/api/event')
// the real hostname must never appear in the proxy path
expect(html).not.toContain('/_scripts/p/plausible.io')
})

it('puts the Google Analytics proxy endpoint on proxy.origin', async () => {
const html = await $fetch<string>('/')
expect(html).toContain('<div id="ga-endpoint">https://t.example.test/_scripts/p/www.google-analytics.com</div>')
})

it('rejects an unknown alias segment (reverse map is consulted)', async () => {
const res = await fetch(url('/_scripts/p/not-a-real-alias/api/event'))
expect(res.status).toBe(403)
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/proxy-alias/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
// Render it so the e2e test can assert it was rewritten to the aliased path.
const scripts = useRuntimeConfig().public.scripts as Record<string, any>
const endpoint = scripts?.plausibleAnalytics?.endpoint ?? 'NO_ENDPOINT'
const gaEndpoint = (useRuntimeConfig().public['nuxt-scripts'] as Record<string, any>)?.proxyEndpoints?.googleAnalytics ?? 'NO_ENDPOINT'
</script>

<template>
<div id="plausible-endpoint">{{ endpoint }}</div>
<div id="ga-endpoint">{{ gaEndpoint }}</div>
</template>
4 changes: 4 additions & 0 deletions test/fixtures/proxy-alias/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { defineNuxtConfig } from 'nuxt/config'
// Minimal fixture for the proxy path alias feature (#814). Plausible is a clean
// single-domain (plausible.io) proxied script with an `autoInject` endpoint, so we
// can assert the alias is wired into both the injected config and the proxy handler.
// `proxy.origin` moves client-facing proxy URLs to another host while the server
// route stays on the relative prefix.
export default defineNuxtConfig({
modules: [
'@nuxt/scripts',
Expand All @@ -20,10 +22,12 @@ export default defineNuxtConfig({

scripts: {
registry: {
googleAnalytics: { id: 'G-TEST' },
instagramEmbed: {},
plausibleAnalytics: { scriptId: 'test-script-id' },
},
proxy: {
origin: 'https://t.example.test',
alias: {
'plausible.io': 'pl',
},
Expand Down
6 changes: 0 additions & 6 deletions test/unit/bundle-sdk-patches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
// direct unit tests of rewriteScriptUrlsAST still passed.
import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform'
import { hash } from 'ohash'
import { hasProtocol } from 'ufo'
import { describe, expect, it, vi } from 'vitest'
import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform'
import { buildProxyConfigsFromRegistry, registry } from '../../packages/script/src/registry'
Expand All @@ -15,10 +14,6 @@ vi.mock('ohash', async (og) => {
const mod = await og<typeof import('ohash')>()
return { ...mod, hash: vi.fn(mod.hash) }
})
vi.mock('ufo', async (og) => {
const mod = await og<typeof import('ufo')>()
return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) }
})

const mockBundleStorage: any = {
getItem: vi.fn(),
Expand All @@ -34,7 +29,6 @@ vi.mock('../../packages/script/src/assets', () => ({
const fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)

vi.mocked(hasProtocol).mockImplementation(() => true)
vi.mocked(hash).mockImplementation(() => 'fathom-script')

const mockNuxt = {
Expand Down
Loading
Loading