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+
export default defineNuxtPlugin({
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+
delete globals.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
const imports = []
115118
const inits = []
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+
const globalMapEntries: string[] = []
122+
const globalInits: string[] = []
116123
const resolvedRegistryKeys: string[] = []
117124
let needsIdleTimeoutImport = false
118125
let needsInteractionImport = false
@@ -211,7 +218,13 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
211218
}
212219
const useFn = `use: () => ({ ${k}: window.${k} })`
213220
const optionsArg = 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
const triggerImports = []
@@ -226,8 +239,20 @@ export function templatePlugin(config: Partial<ModuleOptions>, registry: Require
226239
}
227240

228241
const setupBody: 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
await setup({
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+
const html = 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+
const html = 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+
const html = 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
<div id="globals-runtime">{{ JSON.stringify(rc.public.scriptsGlobals) }}</div>
99
<div id="script-src">{{ $scripts?.trustedShops?.$script?.src || $scripts?.trustedShops?.options?.src || '' }}</div>
10+
<div id="awin-registered">{{ $scripts?.awin ? 'yes' : 'no' }}</div>
11+
<div id="scrads-src">{{ $scripts?.scrads?.$script?.src || $scripts?.scrads?.options?.src || '' }}</div>
12+
<div id="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+
export default defineNuxtPlugin({
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+
delete globals.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
const res = 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
const res = 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
const res = 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+
const res = 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+
const res = 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+
const hookIdx = res.indexOf('callHook(\'scripts:globals\'')
335+
const registerIdx = 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+
const calls: any[] = []
344+
const useScript = (input: any) => {
345+
calls.push(input)
346+
return input
347+
}
348+
const __registerGlobal = (input: any, options: any) => {
349+
if (!input)
350+
return undefined
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' in c))).toBe(true)
365+
})
366+
292367
// Test serviceWorker trigger in globals
293368
it('global with serviceWorker trigger', async () => {
294369
const res = templatePlugin({

0 commit comments

Comments
 (0)