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
32 changes: 32 additions & 0 deletions docs/content/docs/1.guides/4.global.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,38 @@ export default defineNuxtConfig({
})
```

### Overriding the script per deployment

Globals also support runtime overrides via `NUXT_PUBLIC_SCRIPTS_GLOBALS_*` env vars. This lets a single build serve multiple deployments with different third-party IDs (e.g. Trusted Shops, Awin, GTM) without rebuilding.

The env var path mirrors the global's key in `SCREAMING_SNAKE_CASE` (camelCase boundaries become underscores):

```ts [nuxt.config.ts]
export default defineNuxtConfig({
scripts: {
globals: {
trustedShops: {
src: 'https://widgets.trustedshops.com/build-default.js',
},
},
},
})
```

```bash [.env per deployment]
# Override the src for this deployment only:
NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC=https://widgets.trustedshops.com/X1234.js
```

Any input field (e.g. `src`, `integrity`, `crossorigin`, or your own `data-*` attributes) can be overridden this way. The env value replaces the build-time default at runtime via `runtimeConfig.public.scriptsGlobals`.

**Not overridable at runtime:**

- `scriptOptions` (the second tuple slot, e.g. `trigger`, `mode`) and object-form triggers stay baked in at build.
- Asset bundling: the bundle transformer can't statically read `src` through the runtime-config wrapper, so it skips globals that are env-overridable. They load directly from their CDN at runtime. If you need bundling, set `src` statically and skip the env override for that script.

Typos in env var keys are surfaced as dev warnings with suggestions, e.g. `NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC` β†’ suggest `trustedShops`.

### Accessing a global script

All Nuxt Scripts register on the `$scripts` Nuxt App property.
Expand Down
30 changes: 30 additions & 0 deletions packages/script/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@ export default defineNuxtModule<ModuleOptions>({
scripts,
new Set(Object.keys(config.registry || {}).filter(k => (config.registry as any)?.[k] !== false)),
logger,
Object.keys(config.globals || {}),
)

// Setup runtimeConfig for proxies and devtools.
Expand Down Expand Up @@ -720,6 +721,35 @@ export default defineNuxtModule<ModuleOptions>({
}
}

// Expose globals input via runtimeConfig so it can be overridden per
// deployment via NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_<FIELD> env vars
// without rebuilding. The codegen reads + Object.assigns these on plugin setup.
// Must run in the main setup body β€” runtimeConfig is locked in before modules:done.
if (Object.keys(config.globals || {}).length) {
const globalsRuntime: Record<string, Record<string, any>> = {}
for (const [k, c] of Object.entries(config.globals || {})) {
let input: Record<string, any>
if (typeof c === 'string')
input = { src: c }
else if (Array.isArray(c) && c.length === 2)
input = typeof c[0] === 'string' ? { src: c[0] } : { ...c[0] }
else if (typeof c === 'object' && c !== null)
input = { ...(c as Record<string, any>) }
else
continue
// scriptOptions / object-triggers are build-time only β€” they can't
// round-trip through env vars and stay baked into the generated plugin.
delete input.trigger
globalsRuntime[k] = input
}
// Top-level `scriptsGlobals` (camelCase, no hyphen) so Nuxt's standard
// env-var override resolves cleanly: NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_<FIELD>.
nuxt.options.runtimeConfig.public.scriptsGlobals = defu(
globalsRuntime,
nuxt.options.runtimeConfig.public.scriptsGlobals as any,
) as any
}

nuxt.hooks.hook('modules:done', async () => {
const registryScripts = [...scripts]

Expand Down
99 changes: 67 additions & 32 deletions packages/script/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,27 @@ export function registerTypeTemplates({ config, newScripts }: TypeTemplateContex
addTypeTemplate({
filename: 'types/nuxt-scripts-augments.d.ts',
getContents: () => {
const globalsKeys = Object.keys(config.globals || {})
let augments = `// Generated by @nuxt/scripts
declare module '#app' {
interface NuxtApp {
$scripts: Record<${[...[...Object.keys(config.globals || {}), ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext<any> | undefined>
$scripts: Record<${[...[...globalsKeys, ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext<any> | undefined>
_scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance>
}
interface RuntimeNuxtHooks {
'scripts:updated': (ctx: { scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance> }) => void | Promise<void>
}
}
${globalsKeys.length
? `declare module '@nuxt/schema' {
interface PublicRuntimeConfig {
scriptsGlobals?: {
${globalsKeys.map(k => ` ${JSON.stringify(k)}?: Record<string, any>`).join('\n')}
}
}
}
`
: ''}`

if (newScripts.length) {
augments += `
Expand Down Expand Up @@ -142,45 +152,66 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`)
}
}
// Globals input is merged at runtime so `runtimeConfig.public['nuxt-scripts'].globals[<key>]`
// (set via env vars like NUXT_PUBLIC_NUXT_SCRIPTS_GLOBALS_<KEY>_<FIELD>) wins over
// the build-time defaults. scriptOptions/object-triggers stay compile-time.
const hasGlobals = Object.keys(config.globals || {}).length > 0
for (const [k, c] of Object.entries(config.globals || {})) {
let buildInput: Record<string, any>
let extraOptions: Record<string, any> | undefined
if (typeof c === 'string') {
inits.push(`const ${k} = useScript(${JSON.stringify({ src: c, key: k })}, { use: () => ({ ${k}: window.${k} }) })`)
buildInput = { src: c }
}
else if (Array.isArray(c) && c.length === 2) {
const options = c[1]
const triggerResolved = resolveTriggerForTemplate(options?.trigger)
if (triggerResolved) {
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
needsIdleTimeoutImport = true
if (triggerResolved.includes('useScriptTriggerInteraction'))
needsInteractionImport = true
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
needsServiceWorkerImport = true
const resolvedOptions = { ...options, trigger: '__TRIGGER_PLACEHOLDER__' } as any
const optionsJson = JSON.stringify(resolvedOptions).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...(typeof c[0] === 'string' ? { src: c[0] } : c[0]) })}, { ...${optionsJson}, use: () => ({ ${k}: window.${k} }) })`)
}
else {
inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...(typeof c[0] === 'string' ? { src: c[0] } : c[0]) })}, { ...${JSON.stringify(c[1])}, use: () => ({ ${k}: window.${k} }) })`)
}
buildInput = typeof c[0] === 'string' ? { src: c[0] } : { ...(c[0] as any) }
extraOptions = c[1] as any
}
else if (typeof c === 'object' && c !== null) {
const triggerResolved = resolveTriggerForTemplate((c as any).trigger)
if (triggerResolved) {
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
buildInput = { ...(c as any) }
}
else {
continue
}
// Object-form triggers in the input bag need a placeholder substitution after JSON.stringify.
const inputTrigger = buildInput.trigger
const inputTriggerResolved = resolveTriggerForTemplate(inputTrigger)
if (inputTriggerResolved)
buildInput.trigger = '__TRIGGER_PLACEHOLDER__'
let buildInputJson = JSON.stringify(buildInput)
if (inputTriggerResolved)
buildInputJson = buildInputJson.replace(TRIGGER_PLACEHOLDER_RE, inputTriggerResolved)
const inputExpr = `Object.assign({ key: ${JSON.stringify(k)} }, ${buildInputJson}, __scriptsGlobals[${JSON.stringify(k)}] || {})`

// scriptOptions trigger (array form, second slot) β€” same dance, separate JSON.
let optionsJson = ''
if (extraOptions && Object.keys(extraOptions).length > 0) {
const optsCopy: Record<string, any> = { ...extraOptions }
const optsTriggerResolved = resolveTriggerForTemplate(optsCopy.trigger)
if (optsTriggerResolved)
optsCopy.trigger = '__TRIGGER_PLACEHOLDER__'
optionsJson = JSON.stringify(optsCopy)
if (optsTriggerResolved)
optionsJson = optionsJson.replace(TRIGGER_PLACEHOLDER_RE, optsTriggerResolved)
if (optsTriggerResolved) {
if (optsTriggerResolved.includes('useScriptTriggerIdleTimeout'))
needsIdleTimeoutImport = true
if (triggerResolved.includes('useScriptTriggerInteraction'))
if (optsTriggerResolved.includes('useScriptTriggerInteraction'))
needsInteractionImport = true
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
if (optsTriggerResolved.includes('useScriptTriggerServiceWorker'))
needsServiceWorkerImport = true
const resolvedOptions = { ...c, trigger: '__TRIGGER_PLACEHOLDER__' } as any
const argsJson = JSON.stringify({ key: k, ...resolvedOptions }).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
inits.push(`const ${k} = useScript(${argsJson}, { use: () => ({ ${k}: window.${k} }) })`)
}
else {
inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...c })}, { use: () => ({ ${k}: window.${k} }) })`)
}
}
if (inputTriggerResolved) {
if (inputTriggerResolved.includes('useScriptTriggerIdleTimeout'))
needsIdleTimeoutImport = true
if (inputTriggerResolved.includes('useScriptTriggerInteraction'))
needsInteractionImport = true
if (inputTriggerResolved.includes('useScriptTriggerServiceWorker'))
needsServiceWorkerImport = true
}
const useFn = `use: () => ({ ${k}: window.${k} })`
const optionsArg = optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }`
inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`)
}
// Add conditional imports for trigger composables
const triggerImports = []
Expand All @@ -194,9 +225,14 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
triggerImports.push(`import { useScriptTriggerServiceWorker } from '#nuxt-scripts/composables/useScriptTriggerServiceWorker'`)
}

const setupBody: string[] = []
if (hasGlobals)
setupBody.push(` const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}`)
setupBody.push(...inits.map(i => ` ${i}`))
setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`)
return [
`import { useScript } from '#nuxt-scripts/composables/useScript'`,
`import { defineNuxtPlugin } from 'nuxt/app'`,
`import { defineNuxtPlugin${hasGlobals ? ', useRuntimeConfig' : ''} } from 'nuxt/app'`,
...triggerImports,
...imports,
'',
Expand All @@ -205,8 +241,7 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
` env: { islands: false },`,
` parallel: true,`,
` setup() {`,
...inits.map(i => ` ${i}`),
` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`,
...setupBody,
` }`,
`})`,
].join('\n')
Expand Down
45 changes: 44 additions & 1 deletion packages/script/src/validate-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const UPPER_RE = /([A-Z])/g
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()

const ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_'
const GLOBALS_ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_GLOBALS_'

function levenshtein(a: string, b: string): number {
if (a === b)
Expand Down Expand Up @@ -39,7 +40,14 @@ export function validateScriptsEnvVars(
scripts: RegistryScript[],
enabledRegistryKeys: Set<string>,
logger: ConsolaInstance,
globalsKeys: string[] = [],
): void {
// Configured `scripts.globals` keys β€” env vars NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_*
// are validated against these (typo detection, suggestions). Globals are
// schemaless so we can't validate the trailing field name.
const validGlobalsByScreaming = new Map<string, string>()
for (const k of globalsKeys)
validGlobalsByScreaming.set(toScreamingSnake(k), k)
// Build a map from screaming-snake registry key to its envDefaults fields
const validByKey = new Map<string, { camel: string, fields: Set<string> }>()
for (const s of scripts) {
Expand All @@ -50,7 +58,7 @@ export function validateScriptsEnvVars(
validByKey.set(screaming, { camel: s.registryKey, fields })
}

if (!validByKey.size)
if (!validByKey.size && !validGlobalsByScreaming.size)
return

const allValidEnvKeys: string[] = []
Expand All @@ -62,6 +70,41 @@ export function validateScriptsEnvVars(
for (const envKey of Object.keys(process.env)) {
if (!envKey.startsWith(ENV_PREFIX))
continue
// Globals env vars (NUXT_PUBLIC_SCRIPTS_GLOBALS_*) target user-defined keys in
// `scripts.globals`. Validate against the configured globals keys with typo
// suggestions; fields can't be checked (globals are schemaless).
if (envKey.startsWith(GLOBALS_ENV_PREFIX)) {
if (!validGlobalsByScreaming.size)
continue
const segment = envKey.slice(GLOBALS_ENV_PREFIX.length)
const segmentParts = segment.split('_')
let matched = false
for (const [screaming] of validGlobalsByScreaming) {
const keyParts = screaming.split('_')
if (segmentParts.length > keyParts.length
&& keyParts.every((p, i) => segmentParts[i] === p)) {
matched = true
break
}
}
if (matched)
continue
// No exact prefix match β€” suggest the closest configured globals key.
let best: { screaming: string, camel: string, dist: number } | undefined
for (const [screaming, camel] of validGlobalsByScreaming) {
const head = segmentParts.slice(0, screaming.split('_').length).join('_')
const d = levenshtein(head, screaming)
if (!best || d < best.dist)
best = { screaming, camel, dist: d }
}
const suggestion = best && best.dist <= Math.max(2, Math.floor(best.screaming.length / 2))
? ` Did you mean globals key \`${best.camel}\` (\`${GLOBALS_ENV_PREFIX}${best.screaming}_*\`)?`
: ` Configured globals: ${[...validGlobalsByScreaming.values()].map(k => `\`${k}\``).join(', ')}.`
logger.warn(
`[scripts] env var \`${envKey}\` does not map to any configured \`scripts.globals\` key.${suggestion}`,
)
continue
}
if (allValidEnvKeys.includes(envKey))
continue

Expand Down
25 changes: 25 additions & 0 deletions test/e2e/issue-759-globals-env-override.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
import { describe, expect, it } from 'vitest'

const { resolve } = createResolver(import.meta.url)

// Set env vars BEFORE setup() so Nitro picks them up when it builds the server.
// This proves the single-build / multi-deploy contract for issue #759:
// the same build produces different rendered src values depending on env.
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC = 'https://widgets.trustedshops.com/from-env.js'

await setup({
rootDir: resolve('../fixtures/issue-759'),
dev: true,
browser: false,
})

describe('issue-759 globals env override', () => {
it('runtimeConfig.public.scriptsGlobals picks up the env-var override', async () => {
const html = await $fetch<string>('/')
// The fixture serializes rc.public.scriptsGlobals into #globals-runtime.
expect(html).toContain('https://widgets.trustedshops.com/from-env.js')
expect(html).not.toContain('build-default.js')
})
})
11 changes: 11 additions & 0 deletions test/fixtures/issue-759/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
const { $scripts } = useNuxtApp() as any
const rc = useRuntimeConfig()
</script>

<template>
<div>
<div id="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
<div id="script-src">{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}</div>
</div>
</template>
17 changes: 17 additions & 0 deletions test/fixtures/issue-759/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineNuxtConfig } from 'nuxt/config'

// Single build, multi-deployment: src is overridable via
// NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC at server start.
// https://github.com/nuxt/scripts/issues/759
export default defineNuxtConfig({
modules: ['@nuxt/scripts'],
scripts: {
globals: {
trustedShops: [
{ src: 'https://widgets.trustedshops.com/build-default.js' },
{ trigger: 'onNuxtReady' },
],
},
},
compatibilityDate: '2024-07-05',
})
3 changes: 3 additions & 0 deletions test/fixtures/issue-759/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"private": true
}
Loading
Loading