diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 138dc5f00..9fc45c8b3 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -32,7 +32,6 @@ const TEST_RE = /\.(?:test|spec)\./ // it can, so the hook is not called at all for the rest of the graph. const USE_SCRIPT_CODE_MARKER = 'useScript' const UPPERCASE_RE = /^[A-Z]$/ -const USE_SCRIPT_RE = /^useScript/ export type IntegrityAlgorithm = 'sha256' | 'sha384' | 'sha512' @@ -111,6 +110,14 @@ function safeFilename(h: string): string { return `${h.startsWith('-') ? `_${h.slice(1)}` : h}.js` } +// Registry entries pushed through the scripts:registry hook may carry only +// import.name (useScriptMyAnalytics), no registryKey. Their config key follows +// the useRegistryScript convention: strip the prefix, lowercase the first char. +function deriveRegistryKey(fnName: string): string { + const stripped = fnName.replace(/^useScript/, '') + return stripped.charAt(0).toLowerCase() + stripped.slice(1) +} + function buildAssetUrl(filename: string, assetsBaseURL: string = '/_scripts/assets'): string { const nuxt = tryUseNuxt() const cdnURL = nuxt?.options.runtimeConfig?.app?.cdnURL || nuxt?.options.app?.cdnURL || '' @@ -293,12 +300,8 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti let scriptSrcNode: { start: number, end: number, value: any } | undefined let src: false | string | undefined let registryConfig: Record = {} - // Compute registryKey for proxy config lookup + // Registry key for config + proxy lookups, resolved from the registry node below let registryKey: string | undefined - if (fnName !== 'useScript') { - const baseName = fnName.replace(USE_SCRIPT_RE, '') - registryKey = baseName.length > 0 ? baseName.charAt(0).toLowerCase() + baseName.slice(1) : undefined - } if (fnName === 'useScript') { // do easy case first where first argument is a literal if (node.arguments[0]?.type === 'Literal') { @@ -318,6 +321,12 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti // silent failure return } + // The composable name may diverge from the registry key (e.g. + // useScriptTikTokPixel → tiktokPixel), so always use the + // canonical registry key for config and proxy lookups. Entries + // without a registryKey (hook-registered custom scripts) fall + // back to the key derived from the composable name. + registryKey = registryNode.registryKey ?? deriveRegistryKey(fnName) // this is only needed when we have a dynamic src that we need to compute const bundleResolve = getBundleResolve(registryNode as RegistryScript) if (!bundleResolve && !registryNode.src) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2900615e3..bf2fd1db5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,40 +1,6 @@ minimumReleaseAgeExcludePrune: true minimumReleaseAgeExclude: - - '@oxc-parser/binding-android-arm-eabi@0.134.0' - - '@oxc-parser/binding-android-arm64@0.134.0' - - '@oxc-parser/binding-darwin-arm64@0.134.0' - - '@oxc-parser/binding-darwin-x64@0.134.0' - - '@oxc-parser/binding-freebsd-x64@0.134.0' - - '@oxc-parser/binding-linux-arm-gnueabihf@0.134.0' - - '@oxc-parser/binding-linux-arm-musleabihf@0.134.0' - - '@oxc-parser/binding-linux-arm64-gnu@0.134.0' - - '@oxc-parser/binding-linux-arm64-musl@0.134.0' - - '@oxc-parser/binding-linux-ppc64-gnu@0.134.0' - - '@oxc-parser/binding-linux-riscv64-gnu@0.134.0' - - '@oxc-parser/binding-linux-riscv64-musl@0.134.0' - - '@oxc-parser/binding-linux-s390x-gnu@0.134.0' - - '@oxc-parser/binding-linux-x64-gnu@0.134.0' - - '@oxc-parser/binding-linux-x64-musl@0.134.0' - - '@oxc-parser/binding-openharmony-arm64@0.134.0' - - '@oxc-parser/binding-wasm32-wasi@0.134.0' - - '@oxc-parser/binding-win32-arm64-msvc@0.134.0' - - '@oxc-parser/binding-win32-ia32-msvc@0.134.0' - - '@oxc-parser/binding-win32-x64-msvc@0.134.0' - - '@oxc-project/types@0.134.0' - - '@posthog/core@1.30.2' - - '@posthog/types@1.378.1 || 1.409.2' - - oxc-parser@0.134.0 - - posthog-js@1.378.1 || 1.428.7 - - '@nuxt/kit@4.5.1' - - '@nuxt/nitro-server@4.5.1' - - '@nuxt/schema@4.5.1' - - '@nuxt/vite-builder@4.5.1' - - nuxt@4.5.1 - - '@unhead/bundler@3.3.1' - - '@unhead/vue@3.3.1' - - unhead@3.3.1 - '@types/jest-image-snapshot@6.4.2' - - unimport@7.0.1 trustPolicy: no-downgrade trustPolicyIgnoreAfter: 262800 diff --git a/test/fixtures/tiktok-pixel/nuxt.config.ts b/test/fixtures/tiktok-pixel/nuxt.config.ts index 2ef3a03dd..7619f40e2 100644 --- a/test/fixtures/tiktok-pixel/nuxt.config.ts +++ b/test/fixtures/tiktok-pixel/nuxt.config.ts @@ -2,13 +2,16 @@ import { defineNuxtConfig } from 'nuxt/config' // Unbundled fixture: the Pixel SDK loads directly from analytics.tiktok.com so // the real `events.js` runs and we can assert it drains the array-protocol -// queue (the regression guarded by issue #785). +// queue (the regression guarded by issue #785). `bundle: false` is required, +// because the TikTok Pixel registry entry declares the bundle capability and +// the bundler would otherwise download the SDK and rewrite it through the +// first-party proxy. export default defineNuxtConfig({ modules: ['@nuxt/scripts'], scripts: { defaultScriptOptions: { trigger: 'onNuxtReady' }, registry: { - tiktokPixel: { id: 'TEST_PIXEL_ID', defaultConsent: 'granted' }, + tiktokPixel: { id: 'TEST_PIXEL_ID', defaultConsent: 'granted', bundle: false }, }, }, compatibilityDate: '2024-07-05', diff --git a/test/unit/tiktok-pixel-bundle-proxy.test.ts b/test/unit/tiktok-pixel-bundle-proxy.test.ts new file mode 100644 index 000000000..2beb94a48 --- /dev/null +++ b/test/unit/tiktok-pixel-bundle-proxy.test.ts @@ -0,0 +1,102 @@ +// Repro for nuxt/scripts#925. The bundler derived the registry key from the +// composable name (`useScriptTikTokPixel` → `tikTokPixel`) instead of using the +// registry key (`tiktokPixel`), so the first-party proxy config lookup missed +// and bundled pixels kept talking to TikTok directly. +import type { AssetBundlerTransformerOptions } from '../../packages/script/src/plugins/transform' +import type { RegistryScript } from '../../packages/script/src/runtime/types' +import { describe, expect, it, vi } from 'vitest' +import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' +import { buildProxyConfigsFromRegistry, registry } from '../../packages/script/src/registry' + +const mockBundleStorage: any = { + getItem: vi.fn(), + setItem: vi.fn(), + getItemRaw: vi.fn(), + setItemRaw: vi.fn(), + hasItem: vi.fn().mockResolvedValue(false), +} +vi.mock('../../packages/script/src/assets', () => ({ + bundleStorage: vi.fn(() => mockBundleStorage), +})) + +const fetchMock = vi.fn() +vi.stubGlobal('fetch', fetchMock) + +const mockNuxt = { + options: { buildDir: '.nuxt', app: { baseURL: '/' }, runtimeConfig: { app: {} } }, + hooks: { hook: vi.fn() }, +} as any + +function mockUpstream(bytes: Buffer) { + fetchMock.mockResolvedValueOnce({ + ok: true, + arrayBuffer: () => Promise.resolve(bytes), + headers: { get: () => null }, + _data: bytes, + } as any) +} + +async function runTransform(code: string, options: AssetBundlerTransformerOptions) { + const plugin = NuxtScriptBundleTransformer({ ...options, nuxt: mockNuxt }).vite() as any + await plugin.transform.handler.call({}, code, 'file.js') +} + +async function registryEntry(registryKey: string): Promise> { + const scripts = await registry() + const entry = scripts.find(s => s.registryKey === registryKey) + if (!entry) + throw new Error(`registry entry not found: ${registryKey}`) + return entry as Required +} + +describe('bundle transformer resolves proxy config by registry key', () => { + it('rewrites bundled TikTok Pixel requests through the proxy', async () => { + const tiktok = await registryEntry('tiktokPixel') + const proxyConfigs = buildProxyConfigsFromRegistry(await registry()) + mockUpstream(Buffer.from( + `(function(){var e="https://analytics.tiktok.com/i18n/identify";ttq.load("C1234");})();`, + )) + const renderedScript = new Map() + + await runTransform( + `const instance = useScriptTikTokPixel({ id: 'C1234' }, { bundle: true })`, + { + renderedScript, + scripts: [tiktok], + proxyConfigs, + 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('/_scripts/p/analytics.tiktok.com') + expect(content).not.toContain('"https://analytics.tiktok.com') + }) + + it('rewrites bundled LinkedIn Insight requests through the proxy', async () => { + const linkedin = await registryEntry('linkedinInsight') + const proxyConfigs = buildProxyConfigsFromRegistry(await registry()) + mockUpstream(Buffer.from( + `(function(){var e="https://snap.licdn.com/li.lms-analytics/collect";_t.track();})();`, + )) + const renderedScript = new Map() + + await runTransform( + `const instance = useScriptLinkedInInsight({ id: '1234567' }, { bundle: true })`, + { + renderedScript, + scripts: [linkedin], + proxyConfigs, + 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('/_scripts/p/snap.licdn.com') + expect(content).not.toContain('"https://snap.licdn.com') + }) +}) diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index f2ff412a2..6c7e2fec8 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -743,6 +743,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleTagManager', bundle: { resolve(options: any) { if (!options?.id) { @@ -762,6 +763,43 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(code).toMatchInlineSnapshot(`"const instance = useScriptGoogleTagManager({ scriptInput: { src: '/_scripts/assets/951c324253eef4b3.js' } })"`) }) + it('registry config falls back to the derived key when the entry has no registryKey', async () => { + vi.mocked(hash).mockImplementationOnce(src => src.pathname) + let resolveOptions: any + const code = await transform( + `const instance = useScriptMyAnalytics({})`, + { + registryConfig: { + myAnalytics: { + id: 'X', + }, + }, + scripts: [ + { + // hook-style custom registry entry: import.name only, no registryKey + bundle: { + resolve(options: any) { + resolveOptions = options + if (!options?.id) { + return false + } + return `https://analytics.example.com/sdk.js?id=${options.id}` + }, + }, + import: { + name: 'useScriptMyAnalytics', + from: '', + }, + } as any, + ], + }, + ) + expect(resolveOptions).toMatchObject({ id: 'X' }) + // The content-addressed hash depends on shared cache-mock state, so match + // the bundled asset URL by pattern; the behavioural assertion is above. + expect(code).toMatch(/useScriptMyAnalytics\(\{ scriptInput: \{ src: '\/_scripts\/assets\/[a-f0-9]{16}\.js' \}, \}\)/) + }) + describe('configuration merging', () => { it('supports both scripts.registry and runtimeConfig.public.scripts - runtime config takes precedence', async () => { vi.mocked(hash).mockImplementationOnce(src => src.pathname) @@ -777,6 +815,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleTagManager', bundle: { resolve(options: any) { if (!options?.id) { @@ -816,6 +855,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleTagManager', bundle: { resolve(options: any) { if (!options?.id) { @@ -959,6 +999,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleAnalytics', bundle: { resolve(options: any) { if (!options?.id) {