From e527e654c9fa898b1565a7e3e9b03021124f754c Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Tue, 22 Sep 2026 11:17:58 +1000 Subject: [PATCH 1/3] fix(script): use registry key for bundler config and proxy lookups --- packages/script/src/plugins/transform.ts | 11 +-- pnpm-workspace.yaml | 34 ------- test/unit/tiktok-pixel-bundle-proxy.test.ts | 102 ++++++++++++++++++++ test/unit/transform.test.ts | 4 + 4 files changed, 111 insertions(+), 40 deletions(-) create mode 100644 test/unit/tiktok-pixel-bundle-proxy.test.ts diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 138dc5f00..296e64ce5 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' @@ -293,12 +292,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 +313,10 @@ 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. + registryKey = registryNode.registryKey // 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/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..b6a3f93cd 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) { @@ -777,6 +778,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleTagManager', bundle: { resolve(options: any) { if (!options?.id) { @@ -816,6 +818,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleTagManager', bundle: { resolve(options: any) { if (!options?.id) { @@ -959,6 +962,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ }, scripts: [ { + registryKey: 'googleAnalytics', bundle: { resolve(options: any) { if (!options?.id) { From 9a30eb15043d7545ad1530f85793e2fa4a73da76 Mon Sep 17 00:00:00 2001 From: Harlan GitHub Agent Date: Tue, 22 Sep 2026 11:39:26 +1000 Subject: [PATCH 2/3] fix(script): fall back to derived registry key for hook-registered scripts --- packages/script/src/plugins/transform.ts | 14 +++++++-- test/unit/transform.test.ts | 37 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 296e64ce5..9fc45c8b3 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -110,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 || '' @@ -315,8 +323,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti } // 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. - registryKey = registryNode.registryKey + // 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/test/unit/transform.test.ts b/test/unit/transform.test.ts index b6a3f93cd..6c7e2fec8 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -763,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) From 0d8a7ba5de9b51ccee75ac8b6fa52c61518410f2 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 22 Sep 2026 12:30:45 +1000 Subject: [PATCH 3/3] test(tiktok-pixel): opt the fixture out of bundling The fixture loads the real events.js from analytics.tiktok.com so the delivery test can watch the array protocol drain. It stayed unbundled only because the registry key lookup missed, which this branch fixes. With the lookup correct, the bundle capability applies and the SDK is downloaded and proxied, so the endpoint assertion failed. Also restores pnpm-workspace.yaml, which a local pnpm install pruned. --- test/fixtures/tiktok-pixel/nuxt.config.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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',