Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions packages/script/src/runtime/composables/useScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { markRaw, ref } from 'vue'
import { resolveTrigger } from '#build/nuxt-scripts-trigger-resolver'
import { debugEnabled } from '../debug'
import { logger } from '../logger'
import { bindScriptApiResolver } from '../script-api'

type NuxtScriptsApp = ReturnType<typeof useNuxtApp> & {
$scripts: Record<string, UseScriptContext<any> | undefined>
Expand Down Expand Up @@ -167,6 +168,9 @@ export function useScript<T extends Record<symbol | string, any> = Record<symbol
if (!import.meta.client && options.use) {
options.use = (() => undefined) as typeof options.use
}
else if (options.use) {
options.use = bindScriptApiResolver(options.use) as typeof options.use
}

// Partytown quick-path: use useHead for SSR rendering
// Partytown needs scripts in initial HTML with type="text/partytown"
Expand Down
65 changes: 65 additions & 0 deletions packages/script/src/runtime/script-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
type ScriptApi = Record<PropertyKey, any>

function bindMethod(owner: ScriptApi, method: (...args: any[]) => any): (...args: any[]) => any {
const wrapped: (...args: any[]) => any = new Proxy(method, {
apply(target, _receiver, args) {
return Reflect.apply(target, owner, args)
},
construct(target, args, newTarget): object {
return Reflect.construct(target, args, newTarget === wrapped ? target : newTarget)
},
get(target, property) {
return Reflect.get(target, property, target)
},
set(target, property, value) {
return Reflect.set(target, property, value, target)
},
})
return wrapped
}

/**
* Keep vendor methods attached to the object returned by `use()`.
*
* Unhead's loaded script proxy forwards methods with the forwarding proxy as
* `this`. Vendor methods can then reach recursively proxied platform objects,
* which fail native brand checks such as Firefox's Element checks. Returning
* stable method wrappers preserves the vendor API as the receiver while
* retaining queued proxy calls and constructable function properties.
*/
export function bindScriptApiMethods<T>(api: T): T {
if ((typeof api !== 'object' && typeof api !== 'function') || api === null)
return api

const target = api as ScriptApi
const methods = new Map<PropertyKey, { method: (...args: any[]) => any, wrapped: (...args: any[]) => any }>()

return new Proxy(target, {
get(innerTarget, property) {
const value = Reflect.get(innerTarget, property, innerTarget)
if (typeof value !== 'function')
return value

const cached = methods.get(property)
if (cached && cached.method === value)
return cached.wrapped

const wrapped = bindMethod(innerTarget, value)
methods.set(property, { method: value, wrapped })
return wrapped
},
set(innerTarget, property, value) {
methods.delete(property)
return Reflect.set(innerTarget, property, value, innerTarget)
},
}) as T
}

export function bindScriptApiResolver<T>(resolve: () => T | Promise<T>): () => T | Promise<T> {
return () => {
const result = resolve()
return result instanceof Promise
? result.then(bindScriptApiMethods)
: bindScriptApiMethods(result)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
26 changes: 26 additions & 0 deletions test/nuxt-runtime/proxy-receiver.nuxt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { useScript } from '../../packages/script/src/runtime/composables/useScript'

describe('script API proxy receivers', () => {
it('preserves vendor private state when a proxy method is called', () => {
const brandedApis = new WeakSet<object>()
const api = {
readCanvasWidth() {
if (!brandedApis.has(this))
throw new TypeError('Illegal invocation')
return 120
},
}
brandedApis.add(api)
const script = useScript({
key: 'strict-vendor-api',
innerHTML: '',
}, {
trigger: 'manual',
use: () => api,
})

expect(script.proxy.readCanvasWidth()).toBe(120)
script.remove()
})
})
49 changes: 49 additions & 0 deletions test/unit/script-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { bindScriptApiMethods } from '../../packages/script/src/runtime/script-api'

function createForwardingProxy<T extends object>(target: T): T {
const handler: ProxyHandler<object> = {
get(innerTarget, property, receiver) {
const value = Reflect.get(innerTarget, property, receiver)
return typeof value === 'object' && value !== null
? new Proxy(value, handler)
: value
},
}
return new Proxy(target, handler) as T
}

describe('bindScriptApiMethods', () => {
it('preserves the vendor instance as a method receiver through a forwarding proxy', () => {
const brandedCanvas = new WeakSet<object>()
const canvas = {
getBoundingClientRect() {
if (!brandedCanvas.has(this))
throw new TypeError('Illegal invocation')
return { width: 120 }
},
}
brandedCanvas.add(canvas)
const api = {
canvas,
addConfetti() {
return this.canvas.getBoundingClientRect().width
},
}
const proxy = createForwardingProxy(bindScriptApiMethods(api))

expect(proxy.addConfetti()).toBe(120)
})

it('keeps bound method identity stable', () => {
const api = {
call() {
return this
},
}
const bound = bindScriptApiMethods(api)

expect(bound.call).toBe(bound.call)
expect(bound.call()).toBe(api)
})
})
Loading