Skip to content

Commit 8886903

Browse files
authored
feat: env-var overrides for scripts.globals (single-build, multi-deploy) (#780)
1 parent 05d3250 commit 8886903

10 files changed

Lines changed: 307 additions & 40 deletions

File tree

docs/content/docs/1.guides/4.global.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,38 @@ export default defineNuxtConfig({
7474
})
7575
```
7676

77+
### Overriding the script per deployment
78+
79+
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.
80+
81+
The env var path mirrors the global's key in `SCREAMING_SNAKE_CASE` (camelCase boundaries become underscores):
82+
83+
```ts [nuxt.config.ts]
84+
export default defineNuxtConfig({
85+
scripts: {
86+
globals: {
87+
trustedShops: {
88+
src: 'https://widgets.trustedshops.com/build-default.js',
89+
},
90+
},
91+
},
92+
})
93+
```
94+
95+
```bash [.env per deployment]
96+
# Override the src for this deployment only:
97+
NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC=https://widgets.trustedshops.com/X1234.js
98+
```
99+
100+
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`.
101+
102+
**Not overridable at runtime:**
103+
104+
- `scriptOptions` (the second tuple slot, e.g. `trigger`, `mode`) and object-form triggers stay baked in at build.
105+
- 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.
106+
107+
Typos in env var keys are surfaced as dev warnings with suggestions, e.g. `NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOP_SRC` → suggest `trustedShops`.
108+
77109
### Accessing a global script
78110

79111
All Nuxt Scripts register on the `$scripts` Nuxt App property.

packages/script/src/module.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,7 @@ export default defineNuxtModule<ModuleOptions>({
563563
scripts,
564564
new Set(Object.keys(config.registry || {}).filter(k => (config.registry as any)?.[k] !== false)),
565565
logger,
566+
Object.keys(config.globals || {}),
566567
)
567568

568569
// Setup runtimeConfig for proxies and devtools.
@@ -720,6 +721,35 @@ export default defineNuxtModule<ModuleOptions>({
720721
}
721722
}
722723

724+
// Expose globals input via runtimeConfig so it can be overridden per
725+
// deployment via NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_<FIELD> env vars
726+
// without rebuilding. The codegen reads + Object.assigns these on plugin setup.
727+
// Must run in the main setup body — runtimeConfig is locked in before modules:done.
728+
if (Object.keys(config.globals || {}).length) {
729+
const globalsRuntime: Record<string, Record<string, any>> = {}
730+
for (const [k, c] of Object.entries(config.globals || {})) {
731+
let input: Record<string, any>
732+
if (typeof c === 'string')
733+
input = { src: c }
734+
else if (Array.isArray(c) && c.length === 2)
735+
input = typeof c[0] === 'string' ? { src: c[0] } : { ...c[0] }
736+
else if (typeof c === 'object' && c !== null)
737+
input = { ...(c as Record<string, any>) }
738+
else
739+
continue
740+
// scriptOptions / object-triggers are build-time only — they can't
741+
// round-trip through env vars and stay baked into the generated plugin.
742+
delete input.trigger
743+
globalsRuntime[k] = input
744+
}
745+
// Top-level `scriptsGlobals` (camelCase, no hyphen) so Nuxt's standard
746+
// env-var override resolves cleanly: NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_<FIELD>.
747+
nuxt.options.runtimeConfig.public.scriptsGlobals = defu(
748+
globalsRuntime,
749+
nuxt.options.runtimeConfig.public.scriptsGlobals as any,
750+
) as any
751+
}
752+
723753
nuxt.hooks.hook('modules:done', async () => {
724754
const registryScripts = [...scripts]
725755

packages/script/src/templates.ts

Lines changed: 67 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,27 @@ export function registerTypeTemplates({ config, newScripts }: TypeTemplateContex
1717
addTypeTemplate({
1818
filename: 'types/nuxt-scripts-augments.d.ts',
1919
getContents: () => {
20+
const globalsKeys = Object.keys(config.globals || {})
2021
let augments = `// Generated by @nuxt/scripts
2122
declare module '#app' {
2223
interface NuxtApp {
23-
$scripts: Record<${[...[...Object.keys(config.globals || {}), ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext<any> | undefined>
24+
$scripts: Record<${[...[...globalsKeys, ...Object.keys(config.registry || {})].map(k => `'${k}'`), ...['string']].join(' | ')}, import('#nuxt-scripts/types').UseScriptContext<any> | undefined>
2425
_scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance>
2526
}
2627
interface RuntimeNuxtHooks {
2728
'scripts:updated': (ctx: { scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance> }) => void | Promise<void>
2829
}
2930
}
31+
${globalsKeys.length
32+
? `declare module '@nuxt/schema' {
33+
interface PublicRuntimeConfig {
34+
scriptsGlobals?: {
35+
${globalsKeys.map(k => ` ${JSON.stringify(k)}?: Record<string, any>`).join('\n')}
36+
}
37+
}
38+
}
3039
`
40+
: ''}`
3141

3242
if (newScripts.length) {
3343
augments += `
@@ -142,45 +152,66 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
142152
inits.push(`const ${k} = ${importDefinition.import.name}(${argsJson})`)
143153
}
144154
}
155+
// Globals input is merged at runtime so `runtimeConfig.public['nuxt-scripts'].globals[<key>]`
156+
// (set via env vars like NUXT_PUBLIC_NUXT_SCRIPTS_GLOBALS_<KEY>_<FIELD>) wins over
157+
// the build-time defaults. scriptOptions/object-triggers stay compile-time.
158+
const hasGlobals = Object.keys(config.globals || {}).length > 0
145159
for (const [k, c] of Object.entries(config.globals || {})) {
160+
let buildInput: Record<string, any>
161+
let extraOptions: Record<string, any> | undefined
146162
if (typeof c === 'string') {
147-
inits.push(`const ${k} = useScript(${JSON.stringify({ src: c, key: k })}, { use: () => ({ ${k}: window.${k} }) })`)
163+
buildInput = { src: c }
148164
}
149165
else if (Array.isArray(c) && c.length === 2) {
150-
const options = c[1]
151-
const triggerResolved = resolveTriggerForTemplate(options?.trigger)
152-
if (triggerResolved) {
153-
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
154-
needsIdleTimeoutImport = true
155-
if (triggerResolved.includes('useScriptTriggerInteraction'))
156-
needsInteractionImport = true
157-
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
158-
needsServiceWorkerImport = true
159-
const resolvedOptions = { ...options, trigger: '__TRIGGER_PLACEHOLDER__' } as any
160-
const optionsJson = JSON.stringify(resolvedOptions).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
161-
inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...(typeof c[0] === 'string' ? { src: c[0] } : c[0]) })}, { ...${optionsJson}, use: () => ({ ${k}: window.${k} }) })`)
162-
}
163-
else {
164-
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} }) })`)
165-
}
166+
buildInput = typeof c[0] === 'string' ? { src: c[0] } : { ...(c[0] as any) }
167+
extraOptions = c[1] as any
166168
}
167169
else if (typeof c === 'object' && c !== null) {
168-
const triggerResolved = resolveTriggerForTemplate((c as any).trigger)
169-
if (triggerResolved) {
170-
if (triggerResolved.includes('useScriptTriggerIdleTimeout'))
170+
buildInput = { ...(c as any) }
171+
}
172+
else {
173+
continue
174+
}
175+
// Object-form triggers in the input bag need a placeholder substitution after JSON.stringify.
176+
const inputTrigger = buildInput.trigger
177+
const inputTriggerResolved = resolveTriggerForTemplate(inputTrigger)
178+
if (inputTriggerResolved)
179+
buildInput.trigger = '__TRIGGER_PLACEHOLDER__'
180+
let buildInputJson = JSON.stringify(buildInput)
181+
if (inputTriggerResolved)
182+
buildInputJson = buildInputJson.replace(TRIGGER_PLACEHOLDER_RE, inputTriggerResolved)
183+
const inputExpr = `Object.assign({ key: ${JSON.stringify(k)} }, ${buildInputJson}, __scriptsGlobals[${JSON.stringify(k)}] || {})`
184+
185+
// scriptOptions trigger (array form, second slot) — same dance, separate JSON.
186+
let optionsJson = ''
187+
if (extraOptions && Object.keys(extraOptions).length > 0) {
188+
const optsCopy: Record<string, any> = { ...extraOptions }
189+
const optsTriggerResolved = resolveTriggerForTemplate(optsCopy.trigger)
190+
if (optsTriggerResolved)
191+
optsCopy.trigger = '__TRIGGER_PLACEHOLDER__'
192+
optionsJson = JSON.stringify(optsCopy)
193+
if (optsTriggerResolved)
194+
optionsJson = optionsJson.replace(TRIGGER_PLACEHOLDER_RE, optsTriggerResolved)
195+
if (optsTriggerResolved) {
196+
if (optsTriggerResolved.includes('useScriptTriggerIdleTimeout'))
171197
needsIdleTimeoutImport = true
172-
if (triggerResolved.includes('useScriptTriggerInteraction'))
198+
if (optsTriggerResolved.includes('useScriptTriggerInteraction'))
173199
needsInteractionImport = true
174-
if (triggerResolved.includes('useScriptTriggerServiceWorker'))
200+
if (optsTriggerResolved.includes('useScriptTriggerServiceWorker'))
175201
needsServiceWorkerImport = true
176-
const resolvedOptions = { ...c, trigger: '__TRIGGER_PLACEHOLDER__' } as any
177-
const argsJson = JSON.stringify({ key: k, ...resolvedOptions }).replace(TRIGGER_PLACEHOLDER_RE, triggerResolved)
178-
inits.push(`const ${k} = useScript(${argsJson}, { use: () => ({ ${k}: window.${k} }) })`)
179-
}
180-
else {
181-
inits.push(`const ${k} = useScript(${JSON.stringify({ key: k, ...c })}, { use: () => ({ ${k}: window.${k} }) })`)
182202
}
183203
}
204+
if (inputTriggerResolved) {
205+
if (inputTriggerResolved.includes('useScriptTriggerIdleTimeout'))
206+
needsIdleTimeoutImport = true
207+
if (inputTriggerResolved.includes('useScriptTriggerInteraction'))
208+
needsInteractionImport = true
209+
if (inputTriggerResolved.includes('useScriptTriggerServiceWorker'))
210+
needsServiceWorkerImport = true
211+
}
212+
const useFn = `use: () => ({ ${k}: window.${k} })`
213+
const optionsArg = optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }`
214+
inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`)
184215
}
185216
// Add conditional imports for trigger composables
186217
const triggerImports = []
@@ -194,9 +225,14 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
194225
triggerImports.push(`import { useScriptTriggerServiceWorker } from '#nuxt-scripts/composables/useScriptTriggerServiceWorker'`)
195226
}
196227

228+
const setupBody: string[] = []
229+
if (hasGlobals)
230+
setupBody.push(` const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}`)
231+
setupBody.push(...inits.map(i => ` ${i}`))
232+
setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`)
197233
return [
198234
`import { useScript } from '#nuxt-scripts/composables/useScript'`,
199-
`import { defineNuxtPlugin } from 'nuxt/app'`,
235+
`import { defineNuxtPlugin${hasGlobals ? ', useRuntimeConfig' : ''} } from 'nuxt/app'`,
200236
...triggerImports,
201237
...imports,
202238
'',
@@ -205,8 +241,7 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
205241
` env: { islands: false },`,
206242
` parallel: true,`,
207243
` setup() {`,
208-
...inits.map(i => ` ${i}`),
209-
` return { provide: { scripts: { ${[...Object.keys(config.globals || {}), ...resolvedRegistryKeys].join(', ')} } } }`,
244+
...setupBody,
210245
` }`,
211246
`})`,
212247
].join('\n')

packages/script/src/validate-env.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const UPPER_RE = /([A-Z])/g
55
const toScreamingSnake = (s: string) => s.replace(UPPER_RE, '_$1').toUpperCase()
66

77
const ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_'
8+
const GLOBALS_ENV_PREFIX = 'NUXT_PUBLIC_SCRIPTS_GLOBALS_'
89

910
function levenshtein(a: string, b: string): number {
1011
if (a === b)
@@ -39,7 +40,14 @@ export function validateScriptsEnvVars(
3940
scripts: RegistryScript[],
4041
enabledRegistryKeys: Set<string>,
4142
logger: ConsolaInstance,
43+
globalsKeys: string[] = [],
4244
): void {
45+
// Configured `scripts.globals` keys — env vars NUXT_PUBLIC_SCRIPTS_GLOBALS_<KEY>_*
46+
// are validated against these (typo detection, suggestions). Globals are
47+
// schemaless so we can't validate the trailing field name.
48+
const validGlobalsByScreaming = new Map<string, string>()
49+
for (const k of globalsKeys)
50+
validGlobalsByScreaming.set(toScreamingSnake(k), k)
4351
// Build a map from screaming-snake registry key to its envDefaults fields
4452
const validByKey = new Map<string, { camel: string, fields: Set<string> }>()
4553
for (const s of scripts) {
@@ -50,7 +58,7 @@ export function validateScriptsEnvVars(
5058
validByKey.set(screaming, { camel: s.registryKey, fields })
5159
}
5260

53-
if (!validByKey.size)
61+
if (!validByKey.size && !validGlobalsByScreaming.size)
5462
return
5563

5664
const allValidEnvKeys: string[] = []
@@ -62,6 +70,41 @@ export function validateScriptsEnvVars(
6270
for (const envKey of Object.keys(process.env)) {
6371
if (!envKey.startsWith(ENV_PREFIX))
6472
continue
73+
// Globals env vars (NUXT_PUBLIC_SCRIPTS_GLOBALS_*) target user-defined keys in
74+
// `scripts.globals`. Validate against the configured globals keys with typo
75+
// suggestions; fields can't be checked (globals are schemaless).
76+
if (envKey.startsWith(GLOBALS_ENV_PREFIX)) {
77+
if (!validGlobalsByScreaming.size)
78+
continue
79+
const segment = envKey.slice(GLOBALS_ENV_PREFIX.length)
80+
const segmentParts = segment.split('_')
81+
let matched = false
82+
for (const [screaming] of validGlobalsByScreaming) {
83+
const keyParts = screaming.split('_')
84+
if (segmentParts.length > keyParts.length
85+
&& keyParts.every((p, i) => segmentParts[i] === p)) {
86+
matched = true
87+
break
88+
}
89+
}
90+
if (matched)
91+
continue
92+
// No exact prefix match — suggest the closest configured globals key.
93+
let best: { screaming: string, camel: string, dist: number } | undefined
94+
for (const [screaming, camel] of validGlobalsByScreaming) {
95+
const head = segmentParts.slice(0, screaming.split('_').length).join('_')
96+
const d = levenshtein(head, screaming)
97+
if (!best || d < best.dist)
98+
best = { screaming, camel, dist: d }
99+
}
100+
const suggestion = best && best.dist <= Math.max(2, Math.floor(best.screaming.length / 2))
101+
? ` Did you mean globals key \`${best.camel}\` (\`${GLOBALS_ENV_PREFIX}${best.screaming}_*\`)?`
102+
: ` Configured globals: ${[...validGlobalsByScreaming.values()].map(k => `\`${k}\``).join(', ')}.`
103+
logger.warn(
104+
`[scripts] env var \`${envKey}\` does not map to any configured \`scripts.globals\` key.${suggestion}`,
105+
)
106+
continue
107+
}
65108
if (allValidEnvKeys.includes(envKey))
66109
continue
67110

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createResolver } from '@nuxt/kit'
2+
import { $fetch, setup } from '@nuxt/test-utils/e2e'
3+
import { describe, expect, it } from 'vitest'
4+
5+
const { resolve } = createResolver(import.meta.url)
6+
7+
// Set env vars BEFORE setup() so Nitro picks them up when it builds the server.
8+
// This proves the single-build / multi-deploy contract for issue #759:
9+
// the same build produces different rendered src values depending on env.
10+
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC = 'https://widgets.trustedshops.com/from-env.js'
11+
12+
await setup({
13+
rootDir: resolve('../fixtures/issue-759'),
14+
dev: true,
15+
browser: false,
16+
})
17+
18+
describe('issue-759 globals env override', () => {
19+
it('runtimeConfig.public.scriptsGlobals picks up the env-var override', async () => {
20+
const html = await $fetch<string>('/')
21+
// The fixture serializes rc.public.scriptsGlobals into #globals-runtime.
22+
expect(html).toContain('https://widgets.trustedshops.com/from-env.js')
23+
expect(html).not.toContain('build-default.js')
24+
})
25+
})

test/fixtures/issue-759/app.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script setup lang="ts">
2+
const { $scripts } = useNuxtApp() as any
3+
const rc = useRuntimeConfig()
4+
</script>
5+
6+
<template>
7+
<div>
8+
<div id="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
9+
<div id="script-src">{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}</div>
10+
</div>
11+
</template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { defineNuxtConfig } from 'nuxt/config'
2+
3+
// Single build, multi-deployment: src is overridable via
4+
// NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC at server start.
5+
// https://github.com/nuxt/scripts/issues/759
6+
export default defineNuxtConfig({
7+
modules: ['@nuxt/scripts'],
8+
scripts: {
9+
globals: {
10+
trustedShops: [
11+
{ src: 'https://widgets.trustedshops.com/build-default.js' },
12+
{ trigger: 'onNuxtReady' },
13+
],
14+
},
15+
},
16+
compatibilityDate: '2024-07-05',
17+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"private": true
3+
}

0 commit comments

Comments
 (0)