Skip to content
Merged
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
21 changes: 15 additions & 6 deletions packages/script/src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 || ''
Expand Down Expand Up @@ -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<string, any> = {}
// 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') {
Expand All @@ -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)
Expand Down
34 changes: 0 additions & 34 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 5 additions & 2 deletions test/fixtures/tiktok-pixel/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
102 changes: 102 additions & 0 deletions test/unit/tiktok-pixel-bundle-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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<Required<RegistryScript>> {
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<RegistryScript>
}

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')
})
})
41 changes: 41 additions & 0 deletions test/unit/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
},
scripts: [
{
registryKey: 'googleTagManager',
bundle: {
resolve(options: any) {
if (!options?.id) {
Expand All @@ -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)
Expand All @@ -777,6 +815,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
},
scripts: [
{
registryKey: 'googleTagManager',
bundle: {
resolve(options: any) {
if (!options?.id) {
Expand Down Expand Up @@ -816,6 +855,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
},
scripts: [
{
registryKey: 'googleTagManager',
bundle: {
resolve(options: any) {
if (!options?.id) {
Expand Down Expand Up @@ -959,6 +999,7 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
},
scripts: [
{
registryKey: 'googleAnalytics',
bundle: {
resolve(options: any) {
if (!options?.id) {
Expand Down
Loading