Skip to content
Open
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
211 changes: 211 additions & 0 deletions packages/script/src/plugins/rewrite-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SdkPatch, { type: 'replace-script-loader-url' }> =>
p.type === 'replace-script-loader-url') ?? []
const scriptLoaderPathPrefixes = scriptLoaderUrlPatches.map(p => p.pathPrefix)
const scriptLoaderFunctionKeys = new Set<string>()

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<string>()): 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,
Expand Down Expand Up @@ -434,6 +605,46 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
}
}

// SDK patch: replace-new-url-host
// Matches `new URL(<expr>).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
Expand Down
2 changes: 1 addition & 1 deletion packages/script/src/registry-types.json
Original file line number Diff line number Diff line change
Expand Up @@ -3025,7 +3025,7 @@
"ScriptGoogleMapsOverlayViewProps": [
{
"name": "v-model:open",
"type": "boolean",
"type": "boolean | undefined",
"required": false
}
],
Expand Down
8 changes: 7 additions & 1 deletion packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,16 @@ export async function registry(resolve?: (path: string) => Promise<string>): 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'] },
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ defineSlots<ScriptGoogleMapsOverlayViewSlots>()
// 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<boolean>('open', { default: undefined })
const open = defineModel<boolean | undefined>('open', { default: undefined })
if (open.value === undefined)
open.value = defaultOpen ?? true

Expand Down
10 changes: 5 additions & 5 deletions packages/script/src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -273,7 +273,7 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
}
}

const instance = _useScript<T>(input, options as any as UseScriptOptions<T>) as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> & { reload: () => Promise<T> }
const instance = _useScript<T>(input, options as any as UseScriptOptions<T>) as unknown as UseScriptContext<UseFunctionType<NuxtUseScriptOptions<T>, T>> & { reload: () => Promise<T> }
const _remove = instance.remove
instance.remove = () => {
nuxtApp.$scripts[id] = undefined
Expand Down Expand Up @@ -364,7 +364,7 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
const payload: NuxtDevToolsScriptInstance = {
...options.devtools,
src: input.src,
$script: null as any as VueScriptInstance<T>,
$script: null as any,
events: [],
networkRequests: [],
}
Expand All @@ -385,7 +385,7 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
status: ctx.script.status,
at: Date.now(),
})
payload.$script = instance
payload.$script = instance as any
syncScripts()
})
// @ts-expect-error untyped
Expand All @@ -400,7 +400,7 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
})
syncScripts()
})
payload.$script = instance
payload.$script = instance as any
if (err) {
payload.events.push({
type: 'status',
Expand Down
14 changes: 14 additions & 0 deletions packages/script/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(<expr>).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
Expand Down
Loading
Loading