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
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
67 changes: 67 additions & 0 deletions packages/script/src/runtime/script-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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()
const isPromise = result instanceof Promise
|| Object.prototype.toString.call(result) === '[object Promise]'
return isPromise
? (result as Promise<T>).then(bindScriptApiMethods)
: bindScriptApiMethods(result)
}
}
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()
})
})
67 changes: 67 additions & 0 deletions test/unit/script-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { bindScriptApiMethods, bindScriptApiResolver } 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)
})

it('binds APIs resolved by promises from another realm', async () => {
const brandedApis = new WeakSet<object>()
const api = {
call() {
if (!brandedApis.has(this))
throw new TypeError('Illegal invocation')
return 120
},
}
brandedApis.add(api)
const promise = runInNewContext('Promise.resolve(api)', { api }) as Promise<typeof api>
const resolved = await bindScriptApiResolver(() => promise)()
const proxy = createForwardingProxy(resolved)

expect(proxy.call()).toBe(120)
})
})
Loading