From 1c8144a9aacc19fbf0f49e52b7678c62d29b071b Mon Sep 17 00:00:00 2001 From: Felix Gabler Date: Mon, 26 Oct 2026 19:55:42 +0000 Subject: [PATCH] feat(proxy): add proxy.origin for a separate client-facing proxy origin `proxy.origin` resolves the client proxy prefix against another host, so the intercept plugin, bundle rewrites, auto-injected endpoints, the GA proxy endpoint and Partytown `resolveUrl` emit `https:///_scripts/p/...`. The server route, server proxy config, bundled assets and devtools keep the relative prefix. Co-Authored-By: Claude Opus 5.5 --- docs/content/docs/1.guides/2.first-party.md | 20 ++++ docs/content/docs/3.api/5.nuxt-config.md | 17 +++ packages/script/src/module.ts | 18 +++- packages/script/src/plugins/intercept.ts | 4 +- packages/script/src/plugins/rewrite-ast.ts | 24 +++-- test/e2e/proxy-alias.test.ts | 7 +- test/fixtures/proxy-alias/app.vue | 2 + test/fixtures/proxy-alias/nuxt.config.ts | 4 + test/unit/bundle-sdk-patches.test.ts | 6 -- test/unit/proxy-alias-generators.test.ts | 108 ++++++++++++-------- test/unit/rewrite-ast.test.ts | 43 +++++++- 11 files changed, 186 insertions(+), 67 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 68b250928..cd1f0e485 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -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 diff --git a/docs/content/docs/3.api/5.nuxt-config.md b/docs/content/docs/3.api/5.nuxt-config.md index e38ecde1b..83097d71e 100644 --- a/docs/content/docs/3.api/5.nuxt-config.md +++ b/docs/content/docs/3.api/5.nuxt-config.md @@ -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. diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index c0500181a..0deff72b2 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -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. @@ -535,6 +542,7 @@ export default defineNuxtModule({ // 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> = {} @@ -852,7 +860,7 @@ export default defineNuxtModule({ } 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)) @@ -892,7 +900,7 @@ export default defineNuxtModule({ 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 } @@ -900,7 +908,7 @@ export default defineNuxtModule({ // 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 @@ -926,7 +934,7 @@ export default defineNuxtModule({ 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') } @@ -955,7 +963,7 @@ export default defineNuxtModule({ scripts: registryScriptsWithImport, registryConfig: nuxt.options.runtimeConfig.public.scripts as Record | undefined, proxyConfigs, - proxyPrefix, + proxyPrefix: clientProxyPrefix, domainAliases, partytownScripts, moduleDetected(module) { diff --git a/packages/script/src/plugins/intercept.ts b/packages/script/src/plugins/intercept.ts index d9b33d276..321c25edb 100644 --- a/packages/script/src/plugins/intercept.ts +++ b/packages/script/src/plugins/intercept.ts @@ -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; diff --git a/packages/script/src/plugins/rewrite-ast.ts b/packages/script/src/plugins/rewrite-ast.ts index 199f0825b..8af1bc809 100644 --- a/packages/script/src/plugins/rewrite-ast.ts +++ b/packages/script/src/plugins/rewrite-ast.ts @@ -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?:$/ @@ -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) } @@ -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}`) } } @@ -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) { @@ -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}`) } } @@ -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}"`) } } } @@ -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 } } @@ -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() diff --git a/test/e2e/proxy-alias.test.ts b/test/e2e/proxy-alias.test.ts index 5e95e5cc9..e749bd03b 100644 --- a/test/e2e/proxy-alias.test.ts +++ b/test/e2e/proxy-alias.test.ts @@ -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('/') + expect(html).toContain('
https://t.example.test/_scripts/p/www.google-analytics.com
') + }) + 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) diff --git a/test/fixtures/proxy-alias/app.vue b/test/fixtures/proxy-alias/app.vue index 4452ec77d..775149fa5 100644 --- a/test/fixtures/proxy-alias/app.vue +++ b/test/fixtures/proxy-alias/app.vue @@ -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 const endpoint = scripts?.plausibleAnalytics?.endpoint ?? 'NO_ENDPOINT' +const gaEndpoint = (useRuntimeConfig().public['nuxt-scripts'] as Record)?.proxyEndpoints?.googleAnalytics ?? 'NO_ENDPOINT' diff --git a/test/fixtures/proxy-alias/nuxt.config.ts b/test/fixtures/proxy-alias/nuxt.config.ts index 592d8317c..c7f435b0a 100644 --- a/test/fixtures/proxy-alias/nuxt.config.ts +++ b/test/fixtures/proxy-alias/nuxt.config.ts @@ -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', @@ -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', }, diff --git a/test/unit/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts index 522eb9b4b..cba7541a7 100644 --- a/test/unit/bundle-sdk-patches.test.ts +++ b/test/unit/bundle-sdk-patches.test.ts @@ -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' @@ -15,10 +14,6 @@ vi.mock('ohash', async (og) => { const mod = await og() return { ...mod, hash: vi.fn(mod.hash) } }) -vi.mock('ufo', async (og) => { - const mod = await og() - return { ...mod, hasProtocol: vi.fn(mod.hasProtocol) } -}) const mockBundleStorage: any = { getItem: vi.fn(), @@ -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 = { diff --git a/test/unit/proxy-alias-generators.test.ts b/test/unit/proxy-alias-generators.test.ts index f2626a870..d1d350992 100644 --- a/test/unit/proxy-alias-generators.test.ts +++ b/test/unit/proxy-alias-generators.test.ts @@ -6,6 +6,37 @@ import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, registry } const ALIASES = { 'us.i.posthog.com': 'ph' } +/** Runs the generated intercept plugin on `https://my-site.test` and returns the URLs it hands to native fetch. */ +async function fetchThroughIntercept(proxyPrefix: string, urls: string[]): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()) + const generated = generateInterceptPluginContents(proxyPrefix) + .replace('import { defineNuxtPlugin } from \'nuxt/app\'\n\n', '') + .replace('export default ', '') + const sandbox = { + defineNuxtPlugin: (plugin: { setup: () => void }) => plugin, + navigator: { sendBeacon: vi.fn() }, + location: { origin: 'https://my-site.test' }, + XMLHttpRequest: class { open() {} }, + Image: class {}, + HTMLImageElement: class {}, + Request: globalThis.Request, + URL, + fetch: globalThis.fetch, + __nuxtScripts: undefined as { fetch: (url: string) => Promise } | undefined, + } + + try { + const plugin = runInNewContext(generated, sandbox) as { setup: () => void } + plugin.setup() + for (const url of urls) + await sandbox.__nuxtScripts!.fetch(url) + return fetchSpy.mock.calls.map(([url]) => url) + } + finally { + fetchSpy.mockRestore() + } +} + function evaluateResolveUrl(source: string) { return runInNewContext(`(${source})`, { URL }) as ( url: URL, @@ -107,50 +138,39 @@ describe('proxy alias - generated runtime code (#814)', () => { }) it('leaves non-HTTP and same-origin URLs unchanged at runtime', async () => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response()) - const defineNuxtPlugin = (plugin: { setup: () => void }) => plugin - const navigator = { sendBeacon: vi.fn() } - const location = { origin: 'https://my-site.test' } - const XMLHttpRequest = class { open() {} } - const Image = class {} - const HTMLImageElement = class {} - const Request = globalThis.Request - const generated = generateInterceptPluginContents('/_scripts/p') - .replace('import { defineNuxtPlugin } from \'nuxt/app\'\n\n', '') - .replace('export default ', '') - const sandbox = { - defineNuxtPlugin, - navigator, - location, - XMLHttpRequest, - Image, - HTMLImageElement, - Request, - URL, - fetch: globalThis.fetch, - __nuxtScripts: undefined as { fetch: (url: string) => Promise } | undefined, - } - - try { - const plugin = runInNewContext(generated, sandbox) as { setup: () => void } - plugin.setup() - const runtime = sandbox.__nuxtScripts! - - await runtime.fetch('data:text/plain,hello') - await runtime.fetch('javascript:void(0)') - await runtime.fetch('https://my-site.test/collect') - await runtime.fetch('https://constructor/collect') - - expect(fetchSpy.mock.calls.map(([url]) => url)).toEqual([ - 'data:text/plain,hello', - 'javascript:void(0)', - 'https://my-site.test/collect', - 'https://my-site.test/_scripts/p/constructor/collect', - ]) - } - finally { - fetchSpy.mockRestore() - } + expect(await fetchThroughIntercept('/_scripts/p', [ + 'data:text/plain,hello', + 'javascript:void(0)', + 'https://my-site.test/collect', + 'https://constructor/collect', + ])).toEqual([ + 'data:text/plain,hello', + 'javascript:void(0)', + 'https://my-site.test/collect', + 'https://my-site.test/_scripts/p/constructor/collect', + ]) + }) + + it('routes vendor URLs to an absolute proxy origin', async () => { + expect(await fetchThroughIntercept('https://t.example.test/_scripts/p', [ + 'https://www.google-analytics.com/g/collect?v=2', + 'https://t.example.test/_scripts/p/www.google-analytics.com/g/collect?v=2', + 'https://my-site.test/collect', + ])).toEqual([ + 'https://t.example.test/_scripts/p/www.google-analytics.com/g/collect?v=2', + 'https://t.example.test/_scripts/p/www.google-analytics.com/g/collect?v=2', + 'https://my-site.test/collect', + ]) + }) + + it('keeps relative proxy URLs on the page origin', async () => { + expect(await fetchThroughIntercept('/_scripts/p', [ + 'https://www.google-analytics.com/g/collect?v=2', + '/_scripts/p/www.google-analytics.com/g/collect', + ])).toEqual([ + 'https://my-site.test/_scripts/p/www.google-analytics.com/g/collect?v=2', + '/_scripts/p/www.google-analytics.com/g/collect', + ]) }) }) }) diff --git a/test/unit/rewrite-ast.test.ts b/test/unit/rewrite-ast.test.ts index f315cc3ad..3520af183 100644 --- a/test/unit/rewrite-ast.test.ts +++ b/test/unit/rewrite-ast.test.ts @@ -1,4 +1,4 @@ -import type { ProxyRewrite } from '../../packages/script/src/runtime/types' +import type { ProxyRewrite, SdkPatch } from '../../packages/script/src/runtime/types' import { describe, expect, it } from 'vitest' import { rewriteScriptUrlsAST } from '../../packages/script/src/plugins/rewrite-ast' import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, registry } from '../../packages/script/src/registry' @@ -482,6 +482,47 @@ describe('rewriteScriptUrlsAST', () => { }) }) + describe('absolute rewrite targets (proxy.origin)', () => { + const absoluteRewrites = [{ from: 'example.com', to: 'https://t.example.test/_scripts/p/example.com' }] + + function rewriteAbsolute(code: string, sdkPatches?: SdkPatch[]): string { + return rewriteScriptUrlsAST(code, 'test.js', absoluteRewrites, sdkPatches) + } + + it.each([ + ['string literal', 'var u="https://example.com/api"', 'var u="https://t.example.test/_scripts/p/example.com/api"'], + ['split protocol', 'var u="https:"+"//example.com/api"', 'var u="https://t.example.test/_scripts/p/example.com/api"'], + ['protocol-relative literal', 'var u=p+"//example.com/api"', 'var u=p+"https://t.example.test/_scripts/p/example.com/api"'], + ['static template literal', 'var u=`https://example.com/api`', 'var u=`https://t.example.test/_scripts/p/example.com/api`'], + // eslint-disable-next-line no-template-curly-in-string + ['template literal with expressions', 'var u=`https://example.com/api?id=${id}`', 'var u=`https://t.example.test/_scripts/p/example.com/api?id=${id}`'], + ])('emits the absolute target without the page origin: %s', (_, code, expected) => { + expect(rewriteAbsolute(code)).toBe(expected) + }) + + it('emits absolute targets from SDK patches', () => { + expect(rewriteAbsolute('let t=e.split("/script.js")[0];', [ + { type: 'replace-src-split', separator: '/script.js', fromDomain: 'example.com', appendPath: 'api' }, + ])).toBe('let t="https://t.example.test/_scripts/p/example.com/api";') + expect(rewriteAbsolute('var a=new URL(s).origin+"/api";', [ + { type: 'replace-new-url-origin', fromDomain: 'example.com' }, + ])).toBe('var a=("https://t.example.test/_scripts/p/example.com")+"/api";') + expect(rewriteAbsolute([ + 'function load(u){var s=document.createElement("script");s.src=u;}', + 'var base="/settings",path=base+"/"+id+".js";', + 'load(host!==cdn?proto+host+path:make(path));', + ].join(''), [ + { type: 'replace-script-loader-url', fromDomain: 'example.com', pathPrefix: '/settings' }, + ])).toContain('load("https://t.example.test/_scripts/p/example.com"+path);') + }) + + it('keeps prepending the page origin for relative targets', () => { + expect(rewrite('var u="https://example.com/api"')).toBe('var u=self.location.origin+"/_proxy/ex/api"') + // eslint-disable-next-line no-template-curly-in-string + expect(rewrite('var u=`https://example.com/api?id=${id}`')).toBe('var u=self.location.origin+`/_proxy/ex/api?id=${id}`') + }) + }) + describe('existing transforms still work', () => { it('rewrites fetch', () => { expect(rewrite('fetch("https://example.com/api")')).toContain('__nuxtScripts.fetch')