diff --git a/packages/script/src/runtime/composables/useScript.ts b/packages/script/src/runtime/composables/useScript.ts index 49125468..b276095e 100644 --- a/packages/script/src/runtime/composables/useScript.ts +++ b/packages/script/src/runtime/composables/useScript.ts @@ -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 & { $scripts: Record | undefined> @@ -167,6 +168,9 @@ export function useScript = Record 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" diff --git a/packages/script/src/runtime/script-api.ts b/packages/script/src/runtime/script-api.ts new file mode 100644 index 00000000..b568be68 --- /dev/null +++ b/packages/script/src/runtime/script-api.ts @@ -0,0 +1,67 @@ +type ScriptApi = Record + +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(api: T): T { + if ((typeof api !== 'object' && typeof api !== 'function') || api === null) + return api + + const target = api as ScriptApi + const methods = new Map 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(resolve: () => T | Promise): () => T | Promise { + return () => { + const result = resolve() + const isPromise = result instanceof Promise + || Object.prototype.toString.call(result) === '[object Promise]' + return isPromise + ? (result as Promise).then(bindScriptApiMethods) + : bindScriptApiMethods(result) + } +} diff --git a/test/nuxt-runtime/proxy-receiver.nuxt.test.ts b/test/nuxt-runtime/proxy-receiver.nuxt.test.ts new file mode 100644 index 00000000..6e57300c --- /dev/null +++ b/test/nuxt-runtime/proxy-receiver.nuxt.test.ts @@ -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() + 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() + }) +}) diff --git a/test/unit/script-api.test.ts b/test/unit/script-api.test.ts new file mode 100644 index 00000000..146190bc --- /dev/null +++ b/test/unit/script-api.test.ts @@ -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(target: T): T { + const handler: ProxyHandler = { + 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() + 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() + 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 + const resolved = await bindScriptApiResolver(() => promise)() + const proxy = createForwardingProxy(resolved) + + expect(proxy.call()).toBe(120) + }) +})