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+
exportdefaultdefineNuxtConfig({
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
newSet(Object.keys(config.registry||{}).filter(k=>(config.registryasany)?.[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+
constglobalsRuntime: Record<string,Record<string,any>>={}
730+
for(const[k,c]ofObject.entries(config.globals||{})){
731+
letinput: Record<string,any>
732+
if(typeofc==='string')
733+
input={src: c}
734+
elseif(Array.isArray(c)&&c.length===2)
735+
input=typeofc[0]==='string' ? {src: c[0]} : { ...c[0]}
736+
elseif(typeofc==='object'&&c!==null)
737+
input={ ...(casRecord<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+
deleteinput.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.scriptsGlobalsasany,
750+
)asany
751+
}
752+
723753
nuxt.hooks.hook('modules:done',async()=>{
724754
constregistryScripts=[...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+
constglobalsKeys=Object.keys(config.globals||{})
2021
letaugments=`// 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+
consthasGlobals=Object.keys(config.globals||{}).length>0
145159
for(const[k,c]ofObject.entries(config.globals||{})){
160+
letbuildInput: Record<string,any>
161+
letextraOptions: Record<string,any>|undefined
146162
if(typeofc==='string'){
147-
inits.push(`const ${k} = useScript(${JSON.stringify({src: c,key: k})}, { use: () => ({ ${k}: window.${k} }) })`)
163+
buildInput={src: c}
148164
}
149165
elseif(Array.isArray(c)&&c.length===2){
150-
constoptions=c[1]
151-
consttriggerResolved=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-
constresolvedOptions={ ...options,trigger: '__TRIGGER_PLACEHOLDER__'}asany
160-
constoptionsJson=JSON.stringify(resolvedOptions).replace(TRIGGER_PLACEHOLDER_RE,triggerResolved)
161-
inits.push(`const ${k} = useScript(${JSON.stringify({key: k, ...(typeofc[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, ...(typeofc[0]==='string' ? {src: c[0]} : c[0])})}, { ...${JSON.stringify(c[1])}, use: () => ({ ${k}: window.${k} }) })`)
165-
}
166+
buildInput=typeofc[0]==='string' ? {src: c[0]} : { ...(c[0]asany)}
167+
extraOptions=c[1]asany
166168
}
167169
elseif(typeofc==='object'&&c!==null){
168-
consttriggerResolved=resolveTriggerForTemplate((casany).trigger)
169-
if(triggerResolved){
170-
if(triggerResolved.includes('useScriptTriggerIdleTimeout'))
170+
buildInput={ ...(casany)}
171+
}
172+
else{
173+
continue
174+
}
175+
// Object-form triggers in the input bag need a placeholder substitution after JSON.stringify.
176+
constinputTrigger=buildInput.trigger
177+
constinputTriggerResolved=resolveTriggerForTemplate(inputTrigger)
178+
if(inputTriggerResolved)
179+
buildInput.trigger='__TRIGGER_PLACEHOLDER__'
180+
letbuildInputJson=JSON.stringify(buildInput)
181+
if(inputTriggerResolved)
182+
buildInputJson=buildInputJson.replace(TRIGGER_PLACEHOLDER_RE,inputTriggerResolved)
183+
constinputExpr=`Object.assign({ key: ${JSON.stringify(k)} }, ${buildInputJson}, __scriptsGlobals[${JSON.stringify(k)}] || {})`
184+
185+
// scriptOptions trigger (array form, second slot) — same dance, separate JSON.
186+
letoptionsJson=''
187+
if(extraOptions&&Object.keys(extraOptions).length>0){
188+
constoptsCopy: Record<string,any>={ ...extraOptions}
189+
constoptsTriggerResolved=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-
constresolvedOptions={ ...c,trigger: '__TRIGGER_PLACEHOLDER__'}asany
177-
constargsJson=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+
constuseFn=`use: () => ({ ${k}: window.${k} })`
213+
constoptionsArg=optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }`
214+
inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`)
184215
}
185216
// Add conditional imports for trigger composables
186217
consttriggerImports=[]
@@ -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+
constsetupBody: 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
consttoScreamingSnake=(s: string)=>s.replace(UPPER_RE,'_$1').toUpperCase()
66

77
constENV_PREFIX='NUXT_PUBLIC_SCRIPTS_'
8+
constGLOBALS_ENV_PREFIX='NUXT_PUBLIC_SCRIPTS_GLOBALS_'
89

910
functionlevenshtein(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+
constvalidGlobalsByScreaming=newMap<string,string>()
49+
for(constkofglobalsKeys)
50+
validGlobalsByScreaming.set(toScreamingSnake(k),k)
4351
// Build a map from screaming-snake registry key to its envDefaults fields
4452
constvalidByKey=newMap<string,{camel: string,fields: Set<string>}>()
4553
for(constsofscripts){
@@ -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
constallValidEnvKeys: string[]=[]
@@ -62,6 +70,41 @@ export function validateScriptsEnvVars(
6270
for(constenvKeyofObject.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+
constsegment=envKey.slice(GLOBALS_ENV_PREFIX.length)
80+
constsegmentParts=segment.split('_')
81+
letmatched=false
82+
for(const[screaming]ofvalidGlobalsByScreaming){
83+
constkeyParts=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+
letbest: {screaming: string,camel: string,dist: number}|undefined
94+
for(const[screaming,camel]ofvalidGlobalsByScreaming){
95+
consthead=segmentParts.slice(0,screaming.split('_').length).join('_')
96+
constd=levenshtein(head,screaming)
97+
if(!best||d<best.dist)
98+
best={ screaming, camel,dist: d}
99+
}
100+
constsuggestion=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+
awaitsetup({
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+
consthtml=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() asany
3+
const rc =useRuntimeConfig()
4+
</script>
5+
6+
<template>
7+
<div>
8+
<divid="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
9+
<divid="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+
exportdefaultdefineNuxtConfig({
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)