Skip to content

Commit 62b0417

Browse files
authored
feat(globals): runtime disable + scripts:globals hook (#808)
1 parent ac0ad5c commit 62b0417

8 files changed

Lines changed: 200 additions & 10 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,23 @@ NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC=https://widgets.trustedshops.com/X
9999

100100
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`.
101101

102+
#### Disabling a global per deployment
103+
104+
For multi-tenant single-build setups, an instance can skip a global it doesn't use without rebuilding. Set its `enabled` to `false`, or override `src` to an empty value, and the script is never registered for that deployment:
105+
106+
```bash [.env per deployment]
107+
# Drop this integration for this instance only:
108+
NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_SRC=
109+
# or, if you keep an `enabled` field on the build-time default:
110+
NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_ENABLED=false
111+
```
112+
113+
A disabled global resolves to `undefined` on `$scripts`, so guard access (`$scripts.awin?.`) if a script may be turned off per instance.
114+
115+
#### Computing globals at runtime
116+
117+
When env-var overrides aren't enough (you need to compute `src` from runtime config, or conditionally remove entries with logic), tap the [`scripts:globals`](/docs/api/nuxt-app-hooks#scriptsglobals) runtime hook. It hands you a mutable map of your declared globals' resolved inputs right before they register, so you can rewrite or `delete` entries per instance without rebuilding, while they keep their `$scripts` types and asset bundling.
118+
102119
**Not overridable at runtime:**
103120

104121
-`scriptOptions` (the second tuple slot, e.g. `trigger`, `mode`) and object-form triggers stay baked in at build.

‎docs/content/docs/3.api/6.nuxt-app-hooks.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,32 @@ export default defineNuxtPlugin({
2121
})
2222
```
2323

24+
## `scripts:globals`{lang="ts"}
25+
26+
- Type: `(globals: Record<string, Record<string, any>>) => void | Promise<void>`{lang="ts"}
27+
28+
Fired inside the generated `scripts:init` plugin, right before it registers each `scripts.globals` entry. `globals` is a mutable map of your statically declared globals, keyed by each global's key, with values already merged (build-time default first, then any `NUXT_PUBLIC_SCRIPTS_GLOBALS_*` env override). Mutate it to rewrite a `src`/attributes, or `delete` an entry so it never loads, all per instance without a rebuild.
29+
30+
This is the runtime equivalent of a globals factory: it keeps statically declared globals typed on `$scripts` and asset-bundled, while letting you compute their inputs at server/client startup. Deleting an entry (or setting `enabled: false` / an empty `src`) skips its registration; that key then resolves to `undefined` on `$scripts`, so guard access. The hook operates on the declared set only; to load a script that isn't declared in `scripts.globals`, call [`useScript()`{lang="ts"}](/docs/api/use-script) in your own plugin.
31+
32+
Register the listener from an `enforce: 'pre'` plugin so it runs before `scripts:init`, otherwise it arrives too late to fire.
33+
34+
```ts [plugins/nuxt-scripts-globals.ts]
35+
exportdefaultdefineNuxtPlugin({
36+
enforce: 'pre',
37+
setup(nuxtApp) {
38+
const { tenant } =useRuntimeConfig().public
39+
nuxtApp.hooks.hook('scripts:globals', (globals) => {
40+
// Drop an integration this tenant doesn't use:
41+
if (!tenant.awinEnabled)
42+
deleteglobals.awin
43+
// Compute the src from runtime config:
44+
globals.trustedShops.src=`https://widgets.trustedshops.com/${tenant.trustedShopsId}.js`
45+
})
46+
}
47+
})
48+
```
49+
2450
## `script:instance-fn`{lang="ts"} (Unhead Hook)
2551

2652
- Type: `(ctx: { script: ScriptInstance<any>, fn: string | symbol, args: any, exists: boolean }) => HookResult`{lang="ts"}

‎packages/script/src/templates.ts‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ declare module '#app' {
2626
}
2727
interface RuntimeNuxtHooks {
2828
'scripts:updated': (ctx: { scripts: Record<string, import('#nuxt-scripts/types').NuxtDevToolsScriptInstance> }) => void | Promise<void>
29+
// Index signature (not a literal-key Record) so listeners can both \`delete globals[key]\`
30+
// and mutate \`globals[key].src\` without TS2790 / possibly-undefined errors.
31+
'scripts:globals': (globals: Record<string, Record<string, any>>) => void | Promise<void>
2932
}
3033
}
3134
${globalsKeys.length
@@ -113,6 +116,10 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
113116
}
114117
constimports=[]
115118
constinits=[]
119+
// Globals are split out so the resolved inputs can be collected into a mutable map,
120+
// passed through the `scripts:globals` runtime hook, then registered.
121+
constglobalMapEntries: string[]=[]
122+
constglobalInits: string[]=[]
116123
constresolvedRegistryKeys: string[]=[]
117124
letneedsIdleTimeoutImport=false
118125
letneedsInteractionImport=false
@@ -211,7 +218,13 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
211218
}
212219
constuseFn=`use: () => ({ ${k}: window.${k} })`
213220
constoptionsArg=optionsJson ? `{ ...${optionsJson}, ${useFn} }` : `{ ${useFn} }`
214-
inits.push(`const ${k} = useScript(${inputExpr}, ${optionsArg})`)
221+
// Resolved input goes into the mutable `__globals` map keyed by its global key, so the
222+
// `scripts:globals` hook can rewrite/delete/add entries before registration.
223+
globalMapEntries.push(`${JSON.stringify(k)}: ${inputExpr}`)
224+
// Registration is guarded so a runtime override (env or hook) can disable an entry
225+
// per-instance (multi-tenant single-build): set `enabled: false` or an empty/null `src`
226+
// and the script is never registered. See https://github.com/nuxt/scripts/issues/759.
227+
globalInits.push(`const ${k} = __registerGlobal(__globals[${JSON.stringify(k)}], ${optionsArg})`)
215228
}
216229
// Add conditional imports for trigger composables
217230
consttriggerImports=[]
@@ -226,8 +239,20 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
226239
}
227240

228241
constsetupBody: string[]=[]
229-
if(hasGlobals)
242+
if(hasGlobals){
230243
setupBody.push(` const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}`)
244+
// A global is skipped when the merged input disables it: `enabled === false`, or an
245+
// empty/null `src` (the env-overridable way to drop an unused integration per instance).
246+
setupBody.push(` const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) }`)
247+
// Resolved inputs (build-time default <- env override) collected into a mutable map.
248+
setupBody.push(` const __globals = {`)
249+
setupBody.push(...globalMapEntries.map(e=>` ${e},`))
250+
setupBody.push(` }`)
251+
// Runtime hook: userland may rewrite `src`/attrs, delete keys, or add new entries before
252+
// registration. Register the listener from an `enforce: 'pre'` plugin so it runs first.
253+
setupBody.push(` await nuxtApp.hooks.callHook('scripts:globals', __globals)`)
254+
setupBody.push(...globalInits.map(i=>` ${i}`))
255+
}
231256
setupBody.push(...inits.map(i=>` ${i}`))
232257
setupBody.push(` return { provide: { scripts: { ${[...Object.keys(config.globals||{}), ...resolvedRegistryKeys].join(', ')} } } }`)
233258
return[
@@ -240,7 +265,7 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
240265
` name: "scripts:init",`,
241266
` env: { islands: false },`,
242267
` parallel: true,`,
243-
` setup() {`,
268+
` ${hasGlobals ? 'async setup(nuxtApp) {' : 'setup() {'}`,
244269
...setupBody,
245270
` }`,
246271
`})`,

‎test/e2e/issue-759-globals-env-override.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ const { resolve } = createResolver(import.meta.url)
88
// This proves the single-build / multi-deploy contract for issue #759:
99
// the same build produces different rendered src values depending on env.
1010
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_TRUSTED_SHOPS_SRC='https://widgets.trustedshops.com/from-env.js'
11+
// Empty src disables the global for this instance (multi-tenant single build).
12+
process.env.NUXT_PUBLIC_SCRIPTS_GLOBALS_AWIN_SRC=''
1113

1214
awaitsetup({
1315
rootDir: resolve('../fixtures/issue-759'),
@@ -22,4 +24,25 @@ describe('issue-759 globals env override', () => {
2224
expect(html).toContain('https://widgets.trustedshops.com/from-env.js')
2325
expect(html).not.toContain('build-default.js')
2426
})
27+
28+
it('a global with an empty src override is not registered for this instance',async()=>{
29+
consthtml=await$fetch<string>('/')
30+
expect(html).toContain('<div id="awin-registered">no</div>')
31+
})
32+
33+
it('the scripts:globals hook rewrites a global src at runtime',async()=>{
34+
consthtml=await$fetch<string>('/')
35+
// The hook-mutated src is what actually gets registered/preloaded...
36+
expect(html).toContain('href="https://scrads.example/from-hook.js"')
37+
// ...while the un-mutated build-time src is never used for registration.
38+
expect(html).not.toContain('href="https://scrads.example/baked.js"')
39+
})
40+
41+
it('the scripts:globals hook can delete an entry without crashing setup',async()=>{
42+
consthtml=await$fetch<string>('/')
43+
// Page renders (plugin setup didn't throw on the deleted entry)...
44+
expect(html).toContain('<div id="legacy-registered">no</div>')
45+
// ...and the deleted global is never registered/preloaded.
46+
expect(html).not.toContain('href="https://legacy.example/baked.js"')
47+
})
2548
})

‎test/fixtures/issue-759/app.vue‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ const rc = useRuntimeConfig()
77
<div>
88
<divid="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
99
<divid="script-src">{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}</div>
10+
<divid="awin-registered">{{ $scripts?.awin ? 'yes' : 'no' }}</div>
11+
<divid="scrads-src">{{ $scripts?.scrads?.$script?.src || $scripts?.scrads?.options?.src || '' }}</div>
12+
<divid="legacy-registered">{{ $scripts?.legacy ? 'yes' : 'no' }}</div>
1013
</div>
1114
</template>

‎test/fixtures/issue-759/nuxt.config.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ export default defineNuxtConfig({
1111
{src: 'https://widgets.trustedshops.com/build-default.js'},
1212
{trigger: 'onNuxtReady'},
1313
],
14+
// Disabled per-instance via an empty `src` env override (no rebuild).
15+
awin: {src: 'https://www.dwin1.com/build-default.js'},
16+
// `src` rewritten at runtime by the scripts:globals hook (see plugins/globals.ts).
17+
scrads: [
18+
{src: 'https://scrads.example/baked.js'},
19+
{trigger: 'onNuxtReady'},
20+
],
21+
// `delete`d at runtime by the scripts:globals hook; must not crash plugin setup.
22+
legacy: {src: 'https://legacy.example/baked.js'},
1423
},
1524
},
1625
compatibilityDate: '2024-07-05',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// `enforce: 'pre'` so the listener is registered before the module's scripts:init plugin
2+
// runs and fires the hook. Proves runtime mutation of globals per instance (issue #759).
3+
exportdefaultdefineNuxtPlugin({
4+
enforce: 'pre',
5+
setup(nuxtApp){
6+
nuxtApp.hooks.hook('scripts:globals',(globals)=>{
7+
globals.scrads.src='https://scrads.example/from-hook.js'
8+
// Removing an entry must skip its registration without crashing setup.
9+
deleteglobals.legacy
10+
})
11+
},
12+
})

‎test/unit/templates.test.ts‎

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ describe('template plugin file', () => {
3636
stripe: 'https://js.stripe.com/v3/',
3737
},
3838
},[])
39-
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })')
39+
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe"] || {})')
40+
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { use: () => ({ stripe: window.stripe }) })')
4041
})
4142
it('object global',async()=>{
4243
constres=templatePlugin({
@@ -50,7 +51,8 @@ describe('template plugin file', () => {
5051
},
5152
},
5253
},[])
53-
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { use: () => ({ stripe: window.stripe }) })')
54+
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {})')
55+
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { use: () => ({ stripe: window.stripe }) })')
5456
})
5557
it('array global',async()=>{
5658
constres=templatePlugin({
@@ -70,7 +72,8 @@ describe('template plugin file', () => {
7072
],
7173
},
7274
},[])
73-
expect(res).toContain('const stripe = useScript(Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })')
75+
expect(res).toContain('"stripe": Object.assign({ key: "stripe" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe"] || {})')
76+
expect(res).toContain('const stripe = __registerGlobal(__globals["stripe"], { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe: window.stripe }) })')
7477
})
7578
it('mixing global',async()=>{
7679
constres=templatePlugin({
@@ -100,11 +103,18 @@ describe('template plugin file', () => {
100103
name: "scripts:init",
101104
env: { islands: false },
102105
parallel: true,
103-
setup() {
106+
async setup(nuxtApp) {
104107
const __scriptsGlobals = useRuntimeConfig().public.scriptsGlobals || {}
105-
const stripe1 = useScript(Object.assign({ key: "stripe1" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe1"] || {}), { use: () => ({ stripe1: window.stripe1 }) })
106-
const stripe2 = useScript(Object.assign({ key: "stripe2" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe2"] || {}), { use: () => ({ stripe2: window.stripe2 }) })
107-
const stripe3 = useScript(Object.assign({ key: "stripe3" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe3"] || {}), { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) })
108+
const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === '' || rest.src === null) ? undefined : useScript(rest, options) }
109+
const __globals = {
110+
"stripe1": Object.assign({ key: "stripe1" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe1"] || {}),
111+
"stripe2": Object.assign({ key: "stripe2" }, {"async":true,"src":"https://js.stripe.com/v3/","key":"stripe","defer":true,"referrerpolicy":"no-referrer"}, __scriptsGlobals["stripe2"] || {}),
112+
"stripe3": Object.assign({ key: "stripe3" }, {"src":"https://js.stripe.com/v3/"}, __scriptsGlobals["stripe3"] || {}),
113+
}
114+
await nuxtApp.hooks.callHook('scripts:globals', __globals)
115+
const stripe1 = __registerGlobal(__globals["stripe1"], { use: () => ({ stripe1: window.stripe1 }) })
116+
const stripe2 = __registerGlobal(__globals["stripe2"], { use: () => ({ stripe2: window.stripe2 }) })
117+
const stripe3 = __registerGlobal(__globals["stripe3"], { ...{"trigger":"onNuxtReady","mode":"client"}, use: () => ({ stripe3: window.stripe3 }) })
108118
return { provide: { scripts: { stripe1, stripe2, stripe3 } } }
109119
}
110120
})"
@@ -289,6 +299,71 @@ describe('template plugin file', () => {
289299
expect(res).toContain('Object.assign({ key: "trustedShops" }, {"src":"https://widgets.trustedshops.com/build-time.js"}, __scriptsGlobals["trustedShops"] || {})')
290300
})
291301

302+
// A runtime override can drop an unused integration per instance (multi-tenant single
303+
// build) by disabling it: `enabled: false` or an empty/null `src`. The generated plugin
304+
// routes every global through __registerGlobal which skips registration when disabled.
305+
// See https://github.com/nuxt/scripts/issues/759.
306+
it('global registration is guarded so a runtime override can disable it',async()=>{
307+
constres=templatePlugin({
308+
globals: {
309+
awin: {src: 'https://www.dwin1.com/build-time.js'},
310+
},
311+
},[])
312+
// Helper is emitted once and strips `enabled` so it never leaks as a script attribute.
313+
expect(res).toContain('const __registerGlobal = (input, options) => { if (!input) return undefined; const { enabled, ...rest } = input; return (enabled === false || rest.src === \'\' || rest.src === null) ? undefined : useScript(rest, options) }')
314+
// Each global goes through the guard rather than calling useScript directly.
315+
expect(res).toContain('const awin = __registerGlobal(__globals["awin"]')
316+
expect(res).not.toContain('const awin = useScript(')
317+
})
318+
319+
// Resolved inputs are collected into a mutable map and passed through a runtime hook
320+
// before registration, so userland can rewrite/delete/add entries per instance.
321+
// See https://github.com/nuxt/scripts/issues/759.
322+
it('globals are passed through the scripts:globals runtime hook before registration',async()=>{
323+
constres=templatePlugin({
324+
globals: {
325+
awin: {src: 'https://www.dwin1.com/build-time.js'},
326+
},
327+
},[])
328+
// setup is async and receives nuxtApp so the hook can be awaited.
329+
expect(res).toContain('async setup(nuxtApp) {')
330+
// Resolved input lives in the mutable map keyed by the global key.
331+
expect(res).toContain('"awin": Object.assign({ key: "awin" }')
332+
// Hook fires before any registration, with the mutable map.
333+
expect(res).toContain('await nuxtApp.hooks.callHook(\'scripts:globals\', __globals)')
334+
consthookIdx=res.indexOf('callHook(\'scripts:globals\'')
335+
constregisterIdx=res.indexOf('const awin = __registerGlobal')
336+
expect(hookIdx).toBeGreaterThan(-1)
337+
expect(registerIdx).toBeGreaterThan(hookIdx)
338+
})
339+
340+
// The runtime guard itself: enabled:false and empty/null src skip; undefined src (non-src
341+
// globals) and a normal src register.
342+
it('__registerGlobal skips disabled entries and registers the rest',()=>{
343+
constcalls: any[]=[]
344+
constuseScript=(input: any)=>{
345+
calls.push(input)
346+
returninput
347+
}
348+
const__registerGlobal=(input: any,options: any)=>{
349+
if(!input)
350+
returnundefined
351+
const{ enabled, ...rest}=input
352+
return(enabled===false||rest.src===''||rest.src===null) ? undefined : useScript(rest,options)
353+
}
354+
355+
expect(__registerGlobal({key: 'a',src: 'https://e/x.js'},{})).toBeDefined()
356+
expect(__registerGlobal({key: 'b',src: 'https://e/x.js',enabled: false},{})).toBeUndefined()
357+
expect(__registerGlobal({key: 'c',src: ''},{})).toBeUndefined()
358+
expect(__registerGlobal({key: 'd',src: null},{})).toBeUndefined()
359+
// No src field (inline/non-src global) still registers.
360+
expect(__registerGlobal({key: 'e'},{})).toBeDefined()
361+
// A hook that deleted the entry leaves `undefined`; must not throw on destructure.
362+
expect(__registerGlobal(undefined,{})).toBeUndefined()
363+
// `enabled` is stripped, never forwarded to useScript as an attribute.
364+
expect(calls.every(c=>!('enabled'inc))).toBe(true)
365+
})
366+
292367
// Test serviceWorker trigger in globals
293368
it('global with serviceWorker trigger',async()=>{
294369
constres=templatePlugin({

0 commit comments

Comments
 (0)