Skip to content

Commit 05d3250

Browse files
authored
feat: warn on misconfigured NUXT_PUBLIC_SCRIPTS_* env vars (#778)
1 parent fb50b0a commit 05d3250

3 files changed

Lines changed: 260 additions & 0 deletions

File tree

‎packages/script/src/module.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { generateInterceptPluginContents } from './plugins/intercept'
4040
import{NuxtScriptBundleTransformer}from'./plugins/transform'
4141
import{buildProxyConfigsFromRegistry,generatePartytownResolveUrl,getPartytownForwards,registry,resolveCapabilities}from'./registry'
4242
import{registerTypeTemplates,templatePlugin,templateTriggerResolver}from'./templates'
43+
import{validateScriptsEnvVars}from'./validate-env'
4344

4445
exporttype{FirstPartyPrivacy}
4546

@@ -556,6 +557,14 @@ export default defineNuxtModule<ModuleOptions>({
556557
)
557558
}
558559

560+
// Surface env vars under `NUXT_PUBLIC_SCRIPTS_*` that won't be consumed:
561+
// wrong key (typo / marketing name), wrong field, or script not registered.
562+
validateScriptsEnvVars(
563+
scripts,
564+
newSet(Object.keys(config.registry||{}).filter(k=>(config.registryasany)?.[k]!==false)),
565+
logger,
566+
)
567+
559568
// Setup runtimeConfig for proxies and devtools.
560569
// Must run AFTER env var resolution above so the API key is populated.
561570
constgoogleMapsEnabled=config.googleStaticMapsProxy?.enabled||!!config.registry?.googleMaps
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
importtype{ConsolaInstance}from'consola'
2+
importtype{RegistryScript}from'./runtime/types'
3+
4+
constUPPER_RE=/([A-Z])/g
5+
consttoScreamingSnake=(s: string)=>s.replace(UPPER_RE,'_$1').toUpperCase()
6+
7+
constENV_PREFIX='NUXT_PUBLIC_SCRIPTS_'
8+
9+
functionlevenshtein(a: string,b: string): number{
10+
if(a===b)
11+
return0
12+
if(!a.length)
13+
returnb.length
14+
if(!b.length)
15+
returna.length
16+
constprev: number[]=[]
17+
for(letj=0;j<=b.length;j++)prev.push(j)
18+
for(leti=1;i<=a.length;i++){
19+
letprevDiag=prev[0]!
20+
prev[0]=i
21+
for(letj=1;j<=b.length;j++){
22+
consttmp=prev[j]!
23+
prev[j]=a[i-1]===b[j-1]
24+
? prevDiag
25+
: Math.min(prevDiag,prev[j]!,prev[j-1]!)+1
26+
prevDiag=tmp
27+
}
28+
}
29+
returnprev[b.length]!
30+
}
31+
32+
/**
33+
* Warn for `NUXT_PUBLIC_SCRIPTS_*` env vars that don't map to a valid registry
34+
* key + field. Nuxt resolves env vars against runtimeConfig before modules run,
35+
* so a misspelled key (e.g. `NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID` instead
36+
* of `NUXT_PUBLIC_SCRIPTS_CLARITY_ID`) is silently dropped with no error.
37+
*/
38+
exportfunctionvalidateScriptsEnvVars(
39+
scripts: RegistryScript[],
40+
enabledRegistryKeys: Set<string>,
41+
logger: ConsolaInstance,
42+
): void{
43+
// Build a map from screaming-snake registry key to its envDefaults fields
44+
constvalidByKey=newMap<string,{camel: string,fields: Set<string>}>()
45+
for(constsofscripts){
46+
if(!s.registryKey||!s.envDefaults||!Object.keys(s.envDefaults).length)
47+
continue
48+
constscreaming=toScreamingSnake(s.registryKey)
49+
constfields=newSet(Object.keys(s.envDefaults).map(toScreamingSnake))
50+
validByKey.set(screaming,{camel: s.registryKey, fields })
51+
}
52+
53+
if(!validByKey.size)
54+
return
55+
56+
constallValidEnvKeys: string[]=[]
57+
for(const[screaming,{ fields }]ofvalidByKey){
58+
for(constfoffields)
59+
allValidEnvKeys.push(`${ENV_PREFIX}${screaming}_${f}`)
60+
}
61+
62+
for(constenvKeyofObject.keys(process.env)){
63+
if(!envKey.startsWith(ENV_PREFIX))
64+
continue
65+
if(allValidEnvKeys.includes(envKey))
66+
continue
67+
68+
constsegment=envKey.slice(ENV_PREFIX.length)
69+
70+
// Case 1: matches a valid registry key but unknown field
71+
letmatchedKey: {screaming: string,camel: string,fields: Set<string>}|undefined
72+
for(const[screaming,info]ofvalidByKey){
73+
if(segment===screaming||segment.startsWith(`${screaming}_`)){
74+
matchedKey={ screaming, ...info}
75+
break
76+
}
77+
}
78+
79+
if(matchedKey){
80+
constfield=segment.slice(matchedKey.screaming.length+1)
81+
logger.warn(
82+
`[scripts] env var \`${envKey}\` does not match any option on \`${matchedKey.camel}\`. `
83+
+`Valid fields: ${[...matchedKey.fields].map(f=>`\`${ENV_PREFIX}${matchedKey!.screaming}_${f}\``).join(', ')}.${
84+
field ? ` Got: \`${field}\`.` : ''}`,
85+
)
86+
continue
87+
}
88+
89+
// Case 2: registry key appears as a substring of the segment (e.g.
90+
// `MICROSOFT_CLARITY_ID` contains `CLARITY`). Likely a marketing-name
91+
// prefix; suggest the canonical key, and if the remainder is a valid
92+
// field, suggest the full corrected env var.
93+
constsegmentParts=segment.split('_')
94+
letsubstringMatch: {screaming: string,camel: string,fields: Set<string>,remainder: string}|undefined
95+
for(const[screaming,info]ofvalidByKey){
96+
constkeyParts=screaming.split('_')
97+
for(leti=0;i<=segmentParts.length-keyParts.length;i++){
98+
letok=true
99+
for(letj=0;j<keyParts.length;j++){
100+
if(segmentParts[i+j]!==keyParts[j]){
101+
ok=false
102+
break
103+
}
104+
}
105+
if(ok){
106+
substringMatch={
107+
screaming,
108+
camel: info.camel,
109+
fields: info.fields,
110+
remainder: segmentParts.slice(i+keyParts.length).join('_'),
111+
}
112+
break
113+
}
114+
}
115+
if(substringMatch)
116+
break
117+
}
118+
119+
letsuggestion=''
120+
if(substringMatch){
121+
if(substringMatch.remainder&&substringMatch.fields.has(substringMatch.remainder)){
122+
suggestion=` Did you mean \`${ENV_PREFIX}${substringMatch.screaming}_${substringMatch.remainder}\` (registry key \`${substringMatch.camel}\`)?`
123+
}
124+
else{
125+
suggestion=` Did you mean registry key \`${substringMatch.camel}\` (\`${ENV_PREFIX}${substringMatch.screaming}_*\`)?`
126+
}
127+
}
128+
else{
129+
// Fallback: closest registry key by edit distance on the leading parts
130+
letbest: {key: string,camel: string,dist: number}|undefined
131+
for(const[screaming,info]ofvalidByKey){
132+
consthead=segmentParts.slice(0,screaming.split('_').length).join('_')
133+
constd=levenshtein(head,screaming)
134+
if(!best||d<best.dist)
135+
best={key: screaming,camel: info.camel,dist: d}
136+
}
137+
if(best&&best.dist<=Math.max(2,Math.floor(best.key.length/2)))
138+
suggestion=` Did you mean registry key \`${best.camel}\` (\`${ENV_PREFIX}${best.key}_*\`)?`
139+
}
140+
141+
logger.warn(
142+
`[scripts] env var \`${envKey}\` does not map to any registered script.${
143+
suggestion}`,
144+
)
145+
}
146+
147+
// Case 3: env var maps to a known script that the user hasn't enabled in
148+
// their nuxt.config registry. The value gets resolved into runtimeConfig
149+
// but won't be consumed unless the script is registered.
150+
for(const[screaming,info]ofvalidByKey){
151+
if(enabledRegistryKeys.has(info.camel))
152+
continue
153+
for(constfieldofinfo.fields){
154+
constenvKey=`${ENV_PREFIX}${screaming}_${field}`
155+
if(process.env[envKey]!==undefined){
156+
logger.warn(
157+
`[scripts] env var \`${envKey}\` is set but \`${info.camel}\` is not registered in \`scripts.registry\`. `
158+
+`Add \`registry: { ${info.camel}: {} }\` to your nuxt.config for it to take effect.`,
159+
)
160+
}
161+
}
162+
}
163+
}

‎test/unit/validate-env.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
importtype{ConsolaInstance}from'consola'
2+
importtype{RegistryScript}from'../../packages/script/src/runtime/types'
3+
import{afterEach,beforeEach,describe,expect,it,vi}from'vitest'
4+
import{validateScriptsEnvVars}from'../../packages/script/src/validate-env'
5+
6+
constscripts=[
7+
{registryKey: 'clarity',envDefaults: {id: ''}},
8+
{registryKey: 'facebookPixel',envDefaults: {id: ''}},
9+
{registryKey: 'matomoAnalytics',envDefaults: {matomoUrl: '',siteId: ''}},
10+
]asunknownasRegistryScript[]
11+
12+
functionmakeLogger(){
13+
return{warn: vi.fn()}asunknownasConsolaInstance
14+
}
15+
16+
describe('validateScriptsEnvVars',()=>{
17+
constremoveKeys: string[]=[]
18+
19+
beforeEach(()=>{
20+
for(constkofObject.keys(process.env)){
21+
if(k.startsWith('NUXT_PUBLIC_SCRIPTS_'))
22+
deleteprocess.env[k]
23+
}
24+
})
25+
26+
afterEach(()=>{
27+
for(constkofremoveKeys.splice(0))
28+
deleteprocess.env[k]
29+
})
30+
31+
it('does not warn for valid env var on enabled script',()=>{
32+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID='abc'
33+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
34+
constlogger=makeLogger()
35+
validateScriptsEnvVars(scripts,newSet(['clarity']),logger)
36+
expect(logger.warn).not.toHaveBeenCalled()
37+
})
38+
39+
it('warns when env var uses marketing name instead of registry key',()=>{
40+
process.env.NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID='abc'
41+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID')
42+
constlogger=makeLogger()
43+
validateScriptsEnvVars(scripts,newSet(['clarity']),logger)
44+
expect(logger.warn).toHaveBeenCalledTimes(1)
45+
constmsg=(logger.warnasany).mock.calls[0][0]asstring
46+
expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_MICROSOFT_CLARITY_ID')
47+
expect(msg).toContain('clarity')
48+
})
49+
50+
it('warns when field is unknown on a valid key',()=>{
51+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_FOO='x'
52+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_FOO')
53+
constlogger=makeLogger()
54+
validateScriptsEnvVars(scripts,newSet(['clarity']),logger)
55+
expect(logger.warn).toHaveBeenCalledTimes(1)
56+
constmsg=(logger.warnasany).mock.calls[0][0]asstring
57+
expect(msg).toContain('does not match any option on `clarity`')
58+
expect(msg).toContain('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
59+
})
60+
61+
it('warns when env var is set but script not registered',()=>{
62+
process.env.NUXT_PUBLIC_SCRIPTS_CLARITY_ID='abc'
63+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_CLARITY_ID')
64+
constlogger=makeLogger()
65+
validateScriptsEnvVars(scripts,newSet(),logger)
66+
expect(logger.warn).toHaveBeenCalledTimes(1)
67+
constmsg=(logger.warnasany).mock.calls[0][0]asstring
68+
expect(msg).toContain('is not registered')
69+
expect(msg).toContain('clarity')
70+
})
71+
72+
it('handles camelCase registry keys',()=>{
73+
process.env.NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID='abc'
74+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_FACEBOOK_PIXEL_ID')
75+
constlogger=makeLogger()
76+
validateScriptsEnvVars(scripts,newSet(['facebookPixel']),logger)
77+
expect(logger.warn).not.toHaveBeenCalled()
78+
})
79+
80+
it('handles multi-field scripts',()=>{
81+
process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID='1'
82+
process.env.NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL='https://x'
83+
removeKeys.push('NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_SITE_ID','NUXT_PUBLIC_SCRIPTS_MATOMO_ANALYTICS_MATOMO_URL')
84+
constlogger=makeLogger()
85+
validateScriptsEnvVars(scripts,newSet(['matomoAnalytics']),logger)
86+
expect(logger.warn).not.toHaveBeenCalled()
87+
})
88+
})

0 commit comments

Comments
 (0)