From dfd7a13e5b62c5aeae89e49e51f1b0437e912695 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 4 Jul 2026 13:03:31 +1000 Subject: [PATCH 1/3] fix: proxy Snapchat bundled config loads --- packages/script/src/plugins/rewrite-ast.ts | 211 +++++++++++++++++++++ packages/script/src/registry.ts | 8 +- packages/script/src/runtime/types.ts | 14 ++ test/e2e-dev/first-party.test.ts | 18 +- test/fixtures/first-party/nuxt.config.ts | 4 +- test/fixtures/first-party/pages/snap.vue | 4 + test/unit/bundle-sdk-patches.test.ts | 49 +++++ test/unit/rewrite-ast.test.ts | 156 +++++++++++++++ test/unit/transform.test.ts | 45 +++++ 9 files changed, 501 insertions(+), 8 deletions(-) diff --git a/packages/script/src/plugins/rewrite-ast.ts b/packages/script/src/plugins/rewrite-ast.ts index e99e461c3..002a5b7d9 100644 --- a/packages/script/src/plugins/rewrite-ast.ts +++ b/packages/script/src/plugins/rewrite-ast.ts @@ -208,11 +208,182 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites return prev && WORD_OR_DOLLAR_RE.test(prev) ? ' ' : '' } + function sourceOf(node: any): string { + return content.slice(node.start, node.end) + } + // Pass 1: collect all declarations const scopeTracker = new ScopeTracker({ preserveExitedScopes: true }) const { program } = parseAndWalk(content, filename, { scopeTracker }) scopeTracker.freeze() + const scriptLoaderUrlPatches = sdkPatches?.filter((p): p is Extract => + p.type === 'replace-script-loader-url') ?? [] + const scriptLoaderPathPrefixes = scriptLoaderUrlPatches.map(p => p.pathPrefix) + const scriptLoaderFunctionKeys = new Set() + + function isIdentifier(node: any, name?: string): boolean { + return node?.type === 'Identifier' && (!name || node.name === name) + } + + function propertyName(node: any): string | null { + if (!node) + return null + if (node.type === 'Identifier') + return node.name + if (node.type === 'Literal' && typeof node.value === 'string') + return node.value + return null + } + + function firstParamName(node: any): string | null { + const param = node?.params?.[0] + return param?.type === 'Identifier' ? param.name : null + } + + function functionName(node: any, parent: any): string | null { + if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier') + return node.id.name + if ((node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') + && parent?.type === 'VariableDeclarator' + && parent.id?.type === 'Identifier') { + return parent.id.name + } + if ((node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') + && parent?.type === 'AssignmentExpression' + && parent.left?.type === 'Identifier') { + return parent.left.name + } + return null + } + + function declarationKey(name: string): string { + const decl = scopeTracker.getDeclaration(name) + if (decl) { + const node = (decl as any).node + return `${decl.type}:${decl.scope}:${node?.start ?? decl.start}:${node?.end ?? decl.end}` + } + return `global:${name}` + } + + function isScriptLoaderPathLiteral(value: string): boolean { + return scriptLoaderPathPrefixes.some(prefix => value === prefix || value.startsWith(`${prefix}/`)) + } + + function getIdentifierInit(name: string): any | null { + const decl = scopeTracker.getDeclaration(name) + if (!(decl instanceof ScopeTrackerVariable)) + return null + + const declarators = (decl.variableNode as any).declarations + if (!declarators) + return null + + for (const declarator of declarators) { + if (declarator.id?.type === 'Identifier' && declarator.id.name === name) + return declarator.init ?? null + } + return null + } + + function findScriptLoaderPathExpression(node: any, seenIdentifiers = new Set()): any { + if (!node) + return null + if (node.type === 'Identifier') { + if (seenIdentifiers.has(node.name)) + return null + const init = getIdentifierInit(node.name) + if (!init) + return null + seenIdentifiers.add(node.name) + return findScriptLoaderPathExpression(init, seenIdentifiers) ? node : null + } + if (node.type === 'Literal' && typeof node.value === 'string' && isScriptLoaderPathLiteral(node.value)) + return node + if (node.type === 'TemplateLiteral') { + const hasConfigPrefix = node.quasis?.some((q: any) => { + const value = q.value?.cooked ?? q.value?.raw + return typeof value === 'string' && isScriptLoaderPathLiteral(value) + }) + return hasConfigPrefix ? node : null + } + if (node.type === 'CallExpression') { + for (const arg of node.arguments ?? []) { + const found = findScriptLoaderPathExpression(arg, seenIdentifiers) + if (found) + return found + } + return null + } + if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') + return findScriptLoaderPathExpression(node.right, seenIdentifiers) || findScriptLoaderPathExpression(node.left, seenIdentifiers) + if (node.type === 'ConditionalExpression') + return findScriptLoaderPathExpression(node.consequent, seenIdentifiers) || findScriptLoaderPathExpression(node.alternate, seenIdentifiers) + if (node.type === 'SequenceExpression') { + for (let i = node.expressions.length - 1; i >= 0; i--) { + const found = findScriptLoaderPathExpression(node.expressions[i], seenIdentifiers) + if (found) + return found + } + } + return null + } + + function isScriptLoaderFunction(node: any, urlParam: string): boolean { + let matches = false + walk(node.body, { + enter(child) { + if (child !== node.body + && (child.type === 'FunctionDeclaration' || child.type === 'FunctionExpression' || child.type === 'ArrowFunctionExpression')) { + this.skip() + return + } + if (child.type === 'CallExpression' + && child.callee?.type === 'Identifier' + && child.callee.name === 'importScripts' + && isIdentifier(child.arguments?.[0], urlParam)) { + matches = true + } + if (child.type === 'CallExpression' + && child.callee?.type === 'MemberExpression' + && propertyName(child.callee.property) === 'setAttribute' + && child.arguments?.[0]?.type === 'Literal' + && child.arguments[0].value === 'src' + && isIdentifier(child.arguments?.[1], urlParam)) { + matches = true + } + if (child.type === 'Property' + && propertyName(child.key) === 'src' + && isIdentifier(child.value, urlParam)) { + matches = true + } + if (child.type === 'AssignmentExpression' + && child.left?.type === 'MemberExpression' + && propertyName(child.left.property) === 'src' + && isIdentifier(child.right, urlParam)) { + matches = true + } + }, + }) + return matches + } + + if (scriptLoaderUrlPatches.length) { + walk(program, { + scopeTracker, + enter(node, parent) { + if (node.type !== 'FunctionDeclaration' && node.type !== 'FunctionExpression' && node.type !== 'ArrowFunctionExpression') + return + const name = functionName(node, parent) + const param = firstParamName(node) + if (!name || !param) + return + if (isScriptLoaderFunction(node, param)) + scriptLoaderFunctionKeys.add(declarationKey(name)) + }, + }) + } + // Pass 2: rewrite with scope resolution walk(program, { scopeTracker, @@ -434,6 +605,46 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites } } + // SDK patch: replace-new-url-host + // Matches `new URL().host` / `.hostname` and replaces it with a + // known vendor host so bundled scripts do not detect self-hosting. + if (sdkPatches?.some(p => p.type === 'replace-new-url-host') + && node.type === 'MemberExpression' + && !(node as any).computed) { + const obj = (node as any).object + const prop = (node as any).property + if (prop?.type === 'Identifier' && (prop.name === 'host' || prop.name === 'hostname') + && obj?.type === 'NewExpression' + && obj.callee?.type === 'Identifier' && obj.callee.name === 'URL' + && obj.arguments?.length >= 1) { + const patch = sdkPatches.find(p => p.type === 'replace-new-url-host') + if (patch) + s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${JSON.stringify(patch.host)}`) + } + } + + // SDK patch: replace-script-loader-url + // Some SDKs assemble script URLs from minified variables, then pass them + // into a tiny script loader helper. Match the stable behavior: a known + // path prefix reaches a function that uses its first parameter as a + // script `src` or `importScripts` URL. + if (scriptLoaderUrlPatches.length + && node.type === 'CallExpression' + && (node as any).callee?.type === 'Identifier' + && scriptLoaderFunctionKeys.has(declarationKey((node as any).callee.name))) { + const urlArg = (node as any).arguments?.[0] + const pathNode = findScriptLoaderPathExpression(urlArg) + if (urlArg && pathNode) { + for (const patch of scriptLoaderUrlPatches) { + const rewrite = rewrites.find(r => r.from === patch.fromDomain) + if (!rewrite) + continue + s.overwrite(urlArg.start, urlArg.end, `${needsLeadingSpace(urlArg.start)}self.location.origin+"${rewrite.to}"+${sourceOf(pathNode)}`) + break + } + } + } + // new XMLHttpRequest / new Image / new x.XMLHttpRequest / new x.Image if (node.type === 'NewExpression' && !options?.skipApiRewrites) { const callee = (node as any).callee diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 26d09da89..591d7889b 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -519,10 +519,16 @@ export async function registry(resolve?: (path: string) => Promise): Pro src: 'https://sc-static.net/scevent.min.js', category: 'ad', envDefaults: { id: '' }, - bundle: true, + bundle: { + sdkPatches: [{ type: 'replace-new-url-host', host: 'sc-static.net' }], + }, proxy: { domains: ['sc-static.net', 'tr.snapchat.com', 'pixel.tapad.com'], privacy: PRIVACY_FULL, + sdkPatches: [ + { type: 'replace-new-url-host', host: 'sc-static.net' }, + { type: 'replace-script-loader-url', fromDomain: 'tr.snapchat.com', pathPrefix: '/config' }, + ], }, partytown: { forwards: ['snaptr'] }, }), diff --git a/packages/script/src/runtime/types.ts b/packages/script/src/runtime/types.ts index 3bbd1bfae..ff67e6822 100644 --- a/packages/script/src/runtime/types.ts +++ b/packages/script/src/runtime/types.ts @@ -466,6 +466,20 @@ export type SdkPatch * lands on a 404 instead of the proxy. This patch redirects it through the proxy. */ | { type: 'replace-new-url-origin', fromDomain: string } + /** + * Replace `new URL().host` / `.hostname` with a known vendor host. + * Used by SDKs that detect self-hosting from their own script URL and switch + * into a different endpoint mode when bundled. + */ + | { type: 'replace-new-url-host', host: string } + /** + * Replace script loader calls that receive a known path prefix with the + * configured proxied domain. This covers SDKs that assemble URLs from + * minified variables before passing them to a script `src` helper or + * `importScripts`, where ordinary string literal URL rewriting cannot see + * the final host. + */ + | { type: 'replace-script-loader-url', fromDomain: string, pathPrefix: string } /** * Partytown capability config. When present, the script can run in a diff --git a/test/e2e-dev/first-party.test.ts b/test/e2e-dev/first-party.test.ts index 141fcbe49..40cbac6f6 100644 --- a/test/e2e-dev/first-party.test.ts +++ b/test/e2e-dev/first-party.test.ts @@ -563,8 +563,8 @@ describe('first-party privacy stripping', () => { it('bundled scripts contain rewritten collect URLs', async () => { // Check bundled scripts have proxy URLs - const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-proxy') - expect(existsSync(cacheDir), `Bundle proxy cache dir should exist at ${cacheDir}`).toBe(true) + const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-patched') + expect(existsSync(cacheDir), `Patched bundle cache dir should exist at ${cacheDir}`).toBe(true) const files = readdirSync(cacheDir).filter(f => f.endsWith('.js')) expect(files.length).toBeGreaterThan(0) @@ -591,6 +591,7 @@ describe('first-party privacy stripping', () => { let serverOrigin = '' const proxyRequests: string[] = [] const externalRequests: string[] = [] + const sameOriginRequests: string[] = [] page.on('request', (req) => { const reqUrl = req.url() @@ -606,6 +607,9 @@ describe('first-party privacy stripping', () => { if (parsed.pathname.startsWith('/_scripts/p/')) { proxyRequests.push(parsed.pathname) } + else if (serverOrigin && reqUrl.startsWith(serverOrigin)) { + sameOriginRequests.push(parsed.pathname) + } else if (serverOrigin && !reqUrl.startsWith(serverOrigin) && parsed.protocol.startsWith('http')) { externalRequests.push(reqUrl) } @@ -639,7 +643,7 @@ describe('first-party privacy stripping', () => { await page.close() - return { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } + return { captures, rawCaptures, proxyRequests, externalRequests, sameOriginRequests, preClickProxyCount, postClickProxyCount } } /** @@ -818,13 +822,17 @@ describe('first-party privacy stripping', () => { }, 30000) it('snapchatPixel', async () => { - const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('snapchatPixel', '/snap', { + const { captures, rawCaptures, proxyRequests, externalRequests, sameOriginRequests, preClickProxyCount, postClickProxyCount } = await testProvider('snapchatPixel', '/snap', { clickSelectors: ['#trigger-pageview', '#trigger-event'], }) await assertCaptures('snapchatPixel', captures, rawCaptures, proxyRequests, externalRequests, { proxyPrefix: '/_scripts/p/snap', domains: ['snapchat.com'], }, { pre: preClickProxyCount, post: postClickProxyCount }) + expect( + sameOriginRequests.some(path => path.startsWith('/config/')), + `snapchatPixel: bundled SDK requested config from the app origin: ${JSON.stringify(sameOriginRequests.filter(path => path.startsWith('/config/')))}`, + ).toBe(false) }, 30000) it('clarity', async () => { @@ -1111,7 +1119,7 @@ describe('first-party privacy stripping', () => { */ describe('bundled script integrity', () => { it('all cached proxy-rewritten scripts are syntactically valid', async () => { - const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-proxy') + const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-patched') if (!existsSync(cacheDir)) return // skip if no cached scripts diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 666bee640..01945764f 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -1,8 +1,8 @@ import { defineNuxtConfig } from 'nuxt/config' // trigger: 'manual' prevents the auto-generated plugin from loading all 18 -// scripts globally on every page. Each page's composable call then overrides -// the trigger and loads only its own script, eliminating cross-provider noise. +// scripts globally on every page. Pages that need the real SDK load provide an +// explicit trigger in their composable call, keeping cross-provider noise low. const manual = { trigger: 'manual' as const } export default defineNuxtConfig({ diff --git a/test/fixtures/first-party/pages/snap.vue b/test/fixtures/first-party/pages/snap.vue index 0982a863a..9621d4b26 100644 --- a/test/fixtures/first-party/pages/snap.vue +++ b/test/fixtures/first-party/pages/snap.vue @@ -7,6 +7,10 @@ useHead({ const { proxy, status } = useScriptSnapchatPixel({ id: '2295cbcc-cb3f-4727-8c09-1133b742722c', + scriptOptions: { + bundle: 'force', + trigger: 'client', + }, }) function trackPageView() { diff --git a/test/unit/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts index c75ee56ab..2dc41fbbf 100644 --- a/test/unit/bundle-sdk-patches.test.ts +++ b/test/unit/bundle-sdk-patches.test.ts @@ -73,6 +73,13 @@ describe('bundle-only sdkPatches integration', () => { `(function(){var s=document.currentScript;var E=s.getAttribute("data-api")||new URL(s.src).origin+"/api/event";var _=s.getAttribute("data-error")||new URL(s.src).origin+"/api/error";})();`, ) + // Mirrors Snapchat scevent.min.js config bootstrap. When bundled, `S` + // resolves to /_scripts/assets/... and `new URL(S).host` becomes the page + // host. The SDK then requests /config/... from the Nuxt app origin. + const snapchatLike = Buffer.from( + `(function(){var s="sc-static.net",v="https://",l="snapchat.com",S="/_scripts/assets/scevent.min.js";function qr(t){var e={src:t}}var xe=y((function(){return new URL(S).host}),s);function De(t,n,r,e){return void 0===n&&(n=4),e?v+e+t:r?v+xe+t:v+"tr"+(ce()?"-shadow":6===n?"6":"")+"."+l+t}function Ea(t){var i="/config/no/"+t+".js?v=3.59.0";$n(xe,"localhost")?qr(De(i)):C?Sa(t,a):qr(xe!==s?v+xe+i:De(i))}})();`, + ) + it('applies neutralize-domain-check to bundle-only scripts (no proxy)', async () => { mockUpstream(fathomLike) const renderedScript = new Map() @@ -141,6 +148,48 @@ describe('bundle-only sdkPatches integration', () => { expect(content).not.toMatch(/new URL\(s\.src\)\.origin/) }) + it('applies snapchat config host patches to bundled proxy scripts', async () => { + mockUpstream(snapchatLike) + vi.mocked(hash).mockImplementationOnce(() => 'snapchat-script') + const renderedScript = new Map() + + await runTransform( + `const instance = useScriptSnapchatPixel({ id: '2295cbcc-cb3f-4727-8c09-1133b742722c' }, { bundle: true })`, + { + renderedScript, + scripts: [ + { + registryKey: 'snapchatPixel', + src: 'https://sc-static.net/scevent.min.js', + bundle: { + sdkPatches: [{ type: 'replace-new-url-host', host: 'sc-static.net' }], + }, + proxy: 'snapchatPixel', + import: { name: 'useScriptSnapchatPixel', from: '' }, + }, + ] as any, + proxyConfigs: { + snapchatPixel: { + domains: ['sc-static.net', 'tr.snapchat.com', 'pixel.tapad.com'], + sdkPatches: [ + { type: 'replace-new-url-host', host: 'sc-static.net' }, + { type: 'replace-script-loader-url', fromDomain: 'tr.snapchat.com', pathPrefix: '/config' }, + ], + } as any, + }, + proxyPrefix: '/_scripts/p', + }, + ) + + const stored = [...renderedScript.values()][0] + expect(stored, 'bundle was not stored').toBeDefined() + const content = (stored.content as Buffer).toString('utf-8') + expect(content).toContain('return "sc-static.net"') + expect(content).toContain('qr(self.location.origin+"/_scripts/p/tr.snapchat.com"+i)') + expect(content).not.toContain('new URL(S).host') + expect(content).not.toContain('v+xe+i:De(i)') + }) + it('leaves bundles untouched when no patches are configured', async () => { mockUpstream(fathomLike) const renderedScript = new Map() diff --git a/test/unit/rewrite-ast.test.ts b/test/unit/rewrite-ast.test.ts index 13b36fa8a..fac12c2f2 100644 --- a/test/unit/rewrite-ast.test.ts +++ b/test/unit/rewrite-ast.test.ts @@ -241,6 +241,162 @@ describe('rewriteScriptUrlsAST', () => { }) }) + describe('snapchat SDK config host patching', () => { + async function getSnapchatConfig() { + const configs = await getProxyConfigs() + return configs.snapchatPixel + } + + it('pins bundled script host detection to sc-static.net', () => { + const patches = [{ type: 'replace-new-url-host' as const, host: 'sc-static.net' }] + const code = 'var xe=y((function(){return new URL(S).host}),s);' + const result = rewriteScriptUrlsAST(code, 'scevent.min.js', [], patches) + expect(result).toContain('return "sc-static.net"') + expect(result).not.toContain('new URL(S).host') + }) + + it('rewrites the self-hosted config loader branch to the proxy path', async () => { + const snapchatConfig = await getSnapchatConfig() + const snapchatRewrites = deriveRewrites(snapchatConfig.domains, '/_scripts/p') + const code = [ + 'function qr(t){var e={src:t};}', + 'var xe=y((function(){return new URL(S).host}),s);', + 'function De(t,n,r,e){return void 0===n&&(n=4),e?v+e+t:r?v+xe+t:v+"tr"+"."+l+t}', + 'var i="/config/no/id.js?v=3.59.0";', + 'qr(xe!==s?v+xe+i:De(i));', + ].join('') + const result = rewriteScriptUrlsAST(code, 'scevent.min.js', snapchatRewrites, snapchatConfig.sdkPatches) + expect(result).toContain('return "sc-static.net"') + expect(result).toContain('qr(self.location.origin+"/_scripts/p/tr.snapchat.com"+i);') + expect(result).not.toContain('v+xe+i:De(i)') + }) + + it('rewrites the nested production config loader branch to the proxy path', async () => { + const snapchatConfig = await getSnapchatConfig() + const snapchatRewrites = deriveRewrites(snapchatConfig.domains, '/_scripts/p') + const code = [ + 'function qr(t){var e={src:t};}', + 'var xe=y((function(){return new URL(S).host}),s);', + 'function De(t,n,r,e){return void 0===n&&(n=4),e?v+e+t:r?v+xe+t:v+"tr"+(ce()?"-shadow":6===n?"6":"")+"."+l+t}', + 'var pa="/config",r=pa+"/"+n+"/"+t,i=r+".js"+e,a=r+".json"+e;', + '$n(xe,"localhost")?qr(De(i)):C?Sa(t,a):qr(xe!==s?v+xe+i:De(i));', + ].join('') + const result = rewriteScriptUrlsAST(code, 'scevent.min.js', snapchatRewrites, snapchatConfig.sdkPatches) + expect(result).toContain('return "sc-static.net"') + expect(result).toContain('qr(self.location.origin+"/_scripts/p/tr.snapchat.com"+i);') + expect(result).not.toContain('xe!==s?v+xe+i:De(i)') + }) + + it('does not depend on Snapchat minified variable or loader names', async () => { + const snapchatConfig = await getSnapchatConfig() + const snapchatRewrites = deriveRewrites(snapchatConfig.domains, '/_scripts/p') + const code = [ + 'function loadScript(url){importScripts(url);}', + 'var scriptHost=y((function(){return new URL(stackUrl).hostname}),fallbackHost);', + 'function makeUrl(path,mode,useDetected,override){return override?proto+override+path:useDetected?proto+scriptHost+path:proto+"tr"+"."+snapDomain+path}', + 'var base="/config",path=base+"/"+tld+"/"+pixel,jsPath=path+".js"+version;', + 'loadScript(scriptHost!==fallbackHost?proto+scriptHost+jsPath:makeUrl(jsPath));', + ].join('') + const result = rewriteScriptUrlsAST(code, 'scevent.min.js', snapchatRewrites, snapchatConfig.sdkPatches) + expect(result).toContain('return "sc-static.net"') + expect(result).toContain('loadScript(self.location.origin+"/_scripts/p/tr.snapchat.com"+jsPath);') + expect(result).not.toContain('scriptHost!==fallbackHost?proto+scriptHost+jsPath:makeUrl(jsPath)') + }) + }) + + describe('generic script-loader URL patching', () => { + it('rewrites obfuscated script loader URLs by configured path prefix', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'function load(u){var s=document.createElement("script");s.src=u;}', + 'var base="/settings",path=base+"/"+tenant+"/"+id+".js";', + 'load(host!==cdn?proto+host+path:buildVendorUrl(path));', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toContain('load(self.location.origin+"/_scripts/p/config.example.com"+path);') + expect(result).not.toContain('host!==cdn?proto+host+path:buildVendorUrl(path)') + }) + + it('does not rewrite matching path variables passed to non-loader functions', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'function log(u){console.log(u);}', + 'var base="/settings",path=base+"/"+tenant+"/"+id+".js";', + 'log(host!==cdn?proto+host+path:buildVendorUrl(path));', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toBe(code) + }) + + it('keeps minified variable names scoped when detecting path variables', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'function load(u){var s=document.createElement("script");s.src=u;}', + 'var p=proto+cdn+"/sdk.js";', + 'load(p+"?u="+id);', + 'function init(){var p="/settings/"+tenant+"/"+id+".js";load(host!==cdn?proto+host+p:buildVendorUrl(p));}', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toContain('load(p+"?u="+id);') + expect(result).toContain('load(self.location.origin+"/_scripts/p/config.example.com"+p);') + }) + + it('keeps minified loader function names scoped', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'function load(u){console.log(u);}', + 'var path="/settings/"+tenant+"/"+id+".js";', + 'load(path);', + 'function init(){function load(u){var s=document.createElement("script");s.src=u;}load(host!==cdn?proto+host+path:buildVendorUrl(path));}', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toContain('load(path);') + expect(result).toContain('load(self.location.origin+"/_scripts/p/config.example.com"+path);') + }) + + it('keeps function-expression loader declarations distinct in the same var statement', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'var load=function(u){var s=document.createElement("script");s.src=u},log=function(u){console.log(u)};', + 'var path="/settings/"+tenant+"/"+id+".js";', + 'load(host!==cdn?proto+host+path:buildVendorUrl(path));', + 'log(path);', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toContain('load(self.location.origin+"/_scripts/p/config.example.com"+path);') + expect(result).toContain('log(path);') + }) + + it('does not treat wrapper functions as loaders based on nested functions', () => { + const patches = [ + { type: 'replace-script-loader-url' as const, fromDomain: 'config.example.com', pathPrefix: '/settings' }, + ] + const configRewrites = deriveRewrites(['config.example.com'], '/_scripts/p') + const code = [ + 'function wrapper(u){function nested(){var s=document.createElement("script");s.src=u}return nested;}', + 'var path="/settings/"+tenant+"/"+id+".js";', + 'wrapper(path);', + ].join('') + const result = rewriteScriptUrlsAST(code, 'vendor.js', configRewrites, patches) + expect(result).toBe(code) + }) + }) + describe('fathom SDK self-hosted detection patching', () => { // Fathom is bundle-only (no proxy capability) — sdkPatches come from // BundleCapability.sdkPatches and are self-contained with their own domain. diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index 98e994878..ff4e32ce8 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -642,6 +642,51 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(code).toMatchInlineSnapshot(`"const instance = useScript('/_scripts/assets/e3b0c44298fc1c14.js', )"`) }) + it('should bypass cache for registry scriptOptions.bundle force', async () => { + vi.mocked(hash).mockImplementationOnce(() => 'scevent.min') + + // Mock that cache exists and is fresh + mockBundleStorage.hasItem.mockResolvedValue(true) + mockBundleStorage.getItem.mockResolvedValue({ timestamp: Date.now() }) + mockBundleStorage.getItemRaw.mockResolvedValue(Buffer.from('cached content')) + + // Mock successful fetch for force download + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + headers: { get: () => null }, + } as any) + + const code = await transform( + `const instance = useScriptSnapchatPixel({ + id: '2295cbcc-cb3f-4727-8c09-1133b742722c', + scriptOptions: { + bundle: 'force', + trigger: 'client' + } + })`, + { + renderedScript: new Map(), + scripts: [ + { + bundle: { + resolve() { + return 'https://sc-static.net/scevent.min.js' + }, + }, + import: { + name: 'useScriptSnapchatPixel', + from: '', + }, + }, + ] as any, + }, + ) + + expect(fetch).toHaveBeenCalled() + expect(code).toContain('scriptInput: { src: \'/_scripts/assets/e3b0c44298fc1c14.js\' }') + }) + it('should store bundle metadata with timestamp on download', async () => { vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') From f27c4b47c9e84dea13365656d147e5249f1e2436 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Sat, 4 Jul 2026 13:16:16 +1000 Subject: [PATCH 2/3] fix: resolve runtime typecheck errors --- packages/script/src/registry-types.json | 2 +- .../GoogleMaps/ScriptGoogleMapsOverlayView.vue | 2 +- packages/script/src/runtime/composables/useScript.ts | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json index 7a9da743a..464e37cf0 100644 --- a/packages/script/src/registry-types.json +++ b/packages/script/src/registry-types.json @@ -3025,7 +3025,7 @@ "ScriptGoogleMapsOverlayViewProps": [ { "name": "v-model:open", - "type": "boolean", + "type": "boolean | undefined", "required": false } ], diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue index 5223db138..1448bf7fb 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMapsOverlayView.vue @@ -123,7 +123,7 @@ defineSlots() // it accepts `default: undefined`, which opts out of Vue's boolean prop // coercion that would otherwise turn an unset `open` into `false`. We then // seed the local default below if the model is uncontrolled. -const open = defineModel('open', { default: undefined }) +const open = defineModel('open', { default: undefined }) if (open.value === undefined) open.value = defaultOpen ?? true diff --git a/packages/script/src/runtime/composables/useScript.ts b/packages/script/src/runtime/composables/useScript.ts index 1cd59ba05..219afdca2 100644 --- a/packages/script/src/runtime/composables/useScript.ts +++ b/packages/script/src/runtime/composables/useScript.ts @@ -1,4 +1,4 @@ -import type { UseScriptInput, UseScriptOptions, VueScriptInstance } from '@unhead/vue/scripts' +import type { UseScriptInput, UseScriptOptions } from '@unhead/vue/scripts' import type { NuxtDevToolsNetworkRequest, NuxtDevToolsScriptInstance, NuxtUseScriptOptions, UseFunctionType, UseScriptContext } from '../types' import { useScript as _useScript } from '@unhead/vue/scripts' import { defu } from 'defu' @@ -273,7 +273,7 @@ export function useScript = Record(input, options as any as UseScriptOptions) as UseScriptContext, T>> & { reload: () => Promise } + const instance = _useScript(input, options as any as UseScriptOptions) as unknown as UseScriptContext, T>> & { reload: () => Promise } const _remove = instance.remove instance.remove = () => { nuxtApp.$scripts[id] = undefined @@ -364,7 +364,7 @@ export function useScript = Record, + $script: null as any, events: [], networkRequests: [], } @@ -385,7 +385,7 @@ export function useScript = Record = Record Date: Sat, 4 Jul 2026 13:22:56 +1000 Subject: [PATCH 3/3] test: align Snapchat bundle patch coverage --- .../components/GoogleMaps/ScriptGoogleMaps.vue | 4 ++-- test/fixtures/first-party/nuxt.config.ts | 4 ++-- test/unit/bundle-sdk-patches.test.ts | 17 ++++++++++------- test/unit/transform.test.ts | 2 -- 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index f4b155b28..dca551024 100644 --- a/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/packages/script/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -327,9 +327,9 @@ async function resolveQueryToLatLng(query: string) { // Use geocode proxy if available (avoids loading Places library client-side) const endpoints = (runtimeConfig.public['nuxt-scripts'] as any)?.endpoints if (endpoints?.googleMaps) { - const data = await $fetch<{ results: Array<{ geometry: { location: { lat: number, lng: number } } }>, status: string }>(`${scriptsPrefix()}/proxy/google-maps-geocode`, { + const data = await $fetch(`${scriptsPrefix()}/proxy/google-maps-geocode`, { params: { address: query }, - }) + }) as { results: Array<{ geometry: { location: { lat: number, lng: number } } }>, status: string } if (data.status === 'OK' && data.results?.[0]?.geometry?.location) { const loc = data.results[0].geometry.location const latLng = { lat: loc.lat, lng: loc.lng } diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 01945764f..7ddab4c56 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -1,8 +1,8 @@ import { defineNuxtConfig } from 'nuxt/config' // trigger: 'manual' prevents the auto-generated plugin from loading all 18 -// scripts globally on every page. Pages that need the real SDK load provide an -// explicit trigger in their composable call, keeping cross-provider noise low. +// scripts globally on every page. Pages that need the real SDK should provide +// an explicit trigger in their composable call, keeping cross-provider noise low. const manual = { trigger: 'manual' as const } export default defineNuxtConfig({ diff --git a/test/unit/bundle-sdk-patches.test.ts b/test/unit/bundle-sdk-patches.test.ts index 2dc41fbbf..1cd7c7ee2 100644 --- a/test/unit/bundle-sdk-patches.test.ts +++ b/test/unit/bundle-sdk-patches.test.ts @@ -9,6 +9,7 @@ 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' vi.mock('ohash', async (og) => { const mod = await og() @@ -45,6 +46,13 @@ vi.mock('@nuxt/kit', async (og) => { vi.mocked(hasProtocol).mockImplementation(() => true) vi.mocked(hash).mockImplementation(() => 'fathom-script') +let _proxyConfigs: ReturnType | undefined +async function getProxyConfigs() { + if (!_proxyConfigs) + _proxyConfigs = buildProxyConfigsFromRegistry(await registry()) + return _proxyConfigs +} + function mockUpstream(bytes: Buffer) { fetchMock.mockResolvedValueOnce({ ok: true, @@ -152,6 +160,7 @@ describe('bundle-only sdkPatches integration', () => { mockUpstream(snapchatLike) vi.mocked(hash).mockImplementationOnce(() => 'snapchat-script') const renderedScript = new Map() + const proxyConfigs = await getProxyConfigs() await runTransform( `const instance = useScriptSnapchatPixel({ id: '2295cbcc-cb3f-4727-8c09-1133b742722c' }, { bundle: true })`, @@ -169,13 +178,7 @@ describe('bundle-only sdkPatches integration', () => { }, ] as any, proxyConfigs: { - snapchatPixel: { - domains: ['sc-static.net', 'tr.snapchat.com', 'pixel.tapad.com'], - sdkPatches: [ - { type: 'replace-new-url-host', host: 'sc-static.net' }, - { type: 'replace-script-loader-url', fromDomain: 'tr.snapchat.com', pathPrefix: '/config' }, - ], - } as any, + snapchatPixel: proxyConfigs.snapchatPixel, }, proxyPrefix: '/_scripts/p', }, diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index ff4e32ce8..7f4ed929d 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -643,8 +643,6 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }) it('should bypass cache for registry scriptOptions.bundle force', async () => { - vi.mocked(hash).mockImplementationOnce(() => 'scevent.min') - // Mock that cache exists and is fresh mockBundleStorage.hasItem.mockResolvedValue(true) mockBundleStorage.getItem.mockResolvedValue({ timestamp: Date.now() })