Skip to content

Commit 61f2838

Browse files
fix: safer top level await checks (#149)
Co-authored-by: Julien Huang <[email protected]>
1 parent be2389f commit 61f2838

File tree

9 files changed

+341
-247
lines changed

9 files changed

+341
-247
lines changed

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@
7575
"@types/youtube": "^0.0.50",
7676
"@unhead/vue": "^1.9.15",
7777
"@vueuse/core": "^10.11.0",
78-
"acorn": "^8.12.1",
7978
"consola": "^3.2.3",
8079
"defu": "^6.1.4",
8180
"h3": "^1.12.0",

playground/nuxt.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ export default defineNuxtConfig({
33
'@nuxt/scripts',
44
'@nuxt/ui',
55
],
6+
67
devtools: { enabled: true },
8+
compatibilityDate: '2024-07-14',
79
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script lang="ts" setup>
2+
// import { useScript } from '#imports'
3+
4+
// const { $script } = useScript('/test.js')
5+
6+
// uncomment will trigger build-error
7+
// await $script
8+
// or
9+
// await $script.load()
10+
</script>
11+
12+
<template>
13+
<div>hello world</div>
14+
</template>

pnpm-lock.yaml

Lines changed: 108 additions & 34 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import type {
2525
RegistryScript,
2626
RegistryScripts,
2727
} from './runtime/types'
28-
import checkScripts from './plugins/check-scripts'
28+
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
2929
import { templatePlugin } from './templates'
3030
import { addTpc } from './tpc/addTpc'
3131

@@ -116,7 +116,6 @@ export default defineNuxtModule<ModuleOptions>({
116116
return
117117
}
118118
}
119-
addBuildPlugin(checkScripts())
120119
// allow augmenting the options
121120
nuxt.options.alias['#nuxt-scripts-validator'] = resolve(`./runtime/validation/${(nuxt.options.dev || nuxt.options._prepare) ? 'valibot' : 'mock'}`)
122121
nuxt.options.alias['#nuxt-scripts'] = resolve('./runtime/types')
@@ -198,6 +197,10 @@ ${newScripts.map((i) => {
198197
const { normalizeScriptData } = setupPublicAssetStrategy(config.assets)
199198

200199
const moduleInstallPromises: Map<string, () => Promise<boolean> | undefined> = new Map()
200+
201+
addBuildPlugin(NuxtScriptsCheckScripts(), {
202+
dev: true,
203+
})
201204
addBuildPlugin(NuxtScriptBundleTransformer({
202205
scripts: registryScriptsWithImport,
203206
defaultBundle: config.defaultScriptOptions?.bundle,

src/plugins/check-scripts.ts

Lines changed: 58 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,63 @@
11
import { createUnplugin } from 'unplugin'
2-
import type { AnyNode, VariableDeclarator, ExportDefaultDeclaration, Property } from 'acorn'
3-
import { extname } from 'pathe'
4-
import { parse } from 'acorn'
5-
import { transform } from 'esbuild'
6-
7-
const SCRIPT_RE = /<script[^>]*>([\s\S]*)<\/script>/
8-
9-
export default () => createUnplugin(() => {
10-
return {
11-
name: 'nuxt-scripts:check-scripts',
12-
enforce: 'pre',
13-
async transform(code, id) {
14-
if (!code.includes('useScript')) // all integrations should start with useScript*
15-
return
16-
17-
const extName = extname(id)
18-
if (extName === '.vue') {
19-
const scriptAst = await extractScriptContentAst(code)
20-
if (scriptAst) {
21-
analyzeNodes(id, scriptAst)
22-
}
23-
}
24-
else if (extName === '.ts' || extName === '.js') {
25-
if (!code.includes('defineComponent')) return
26-
27-
let result = code
28-
29-
if (extName === '.ts') {
30-
result = (await transform(code, { loader: 'ts' })).code
31-
}
32-
33-
const setupFunction = extractSetupFunction(result)
34-
35-
if (setupFunction) {
36-
analyzeNodes(id, setupFunction)
37-
}
38-
}
39-
40-
return undefined
41-
},
42-
}
43-
})
44-
45-
function analyzeNodes(id: string, nodes: AnyNode[]) {
46-
let name: string | undefined
47-
48-
for (const node of nodes) {
49-
if (name) {
50-
if (isAwaitingLoad(name, node)) {
51-
throw new Error('Awaiting load should not be used at top level of a composable or <script>')
52-
}
53-
}
54-
else {
55-
if (node.type === 'VariableDeclaration') {
56-
name = findScriptVar(node.declarations[0])
57-
}
58-
}
59-
}
60-
}
61-
62-
function findScriptVar(scriptDeclaration: VariableDeclarator) {
63-
if (scriptDeclaration.id.type === 'ObjectPattern') {
64-
for (const property of scriptDeclaration.id.properties) {
65-
if (property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === '$script' && property.value.type === 'Identifier') {
66-
return property.value.name
67-
}
68-
}
69-
}
70-
else if (scriptDeclaration.id.type === 'Identifier') {
71-
return scriptDeclaration.id.name
72-
}
73-
}
74-
75-
function isAwaitingLoad(name: string, node: AnyNode) {
76-
if (node.type === 'ExpressionStatement' && node.expression.type === 'AwaitExpression') {
77-
const arg = node.expression.argument
78-
if (arg.type === 'CallExpression') {
79-
const callee = arg.callee
80-
if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier' && callee.object.name === name) {
81-
// $script or alias is used
82-
if (callee.property.type === 'Identifier' && callee.property.name === 'load') {
83-
return true
2+
import { type Node, walk } from 'estree-walker'
3+
import type { AssignmentExpression, CallExpression, ObjectPattern, ArrowFunctionExpression, Identifier, MemberExpression } from 'estree'
4+
import { isVue } from './util'
5+
6+
export function NuxtScriptsCheckScripts() {
7+
return createUnplugin(() => {
8+
return {
9+
name: 'nuxt-scripts:check-scripts',
10+
transformInclude(id) {
11+
return isVue(id, { type: ['script'] })
12+
},
13+
14+
async transform(code) {
15+
if (!code.includes('useScript')) // all integrations should start with useScript*
16+
return
17+
18+
const ast = this.parse(code)
19+
let nameNode: Node | undefined
20+
let errorNode: Node | undefined
21+
walk(ast as Node, {
22+
enter(_node) {
23+
if (_node.type === 'VariableDeclaration' && _node.declarations?.[0]?.id?.type === 'ObjectPattern') {
24+
const objPattern = _node.declarations[0]?.id as ObjectPattern
25+
for (const property of objPattern.properties) {
26+
if (property.type === 'Property' && property.key.type === 'Identifier' && property.key.name === '$script' && property.value.type === 'Identifier') {
27+
nameNode = _node
28+
}
29+
}
30+
}
31+
if (nameNode) {
32+
let sequence = _node.type === 'SequenceExpression' ? _node : null
33+
let assignmentExpression
34+
if (_node.type === 'VariableDeclaration') {
35+
if (_node.declarations[0]?.init?.type === 'SequenceExpression') {
36+
sequence = _node.declarations[0]?.init
37+
assignmentExpression = _node.declarations[0]?.init?.expressions?.[0]
38+
}
39+
}
40+
if (sequence && !assignmentExpression) {
41+
assignmentExpression = (sequence.expressions[0]?.type === 'AssignmentExpression' ? sequence.expressions[0] : null)
42+
}
43+
if (assignmentExpression) {
44+
// check right call expression is calling $script
45+
const right = (assignmentExpression as AssignmentExpression)?.right as CallExpression
46+
// @ts-expect-error untyped
47+
if (right.callee?.name === '_withAsyncContext') {
48+
if (((right.arguments[0] as ArrowFunctionExpression)?.body as Identifier)?.name === '$script'
49+
|| ((((right.arguments[0] as ArrowFunctionExpression)?.body as CallExpression)?.callee as MemberExpression)?.object as Identifier)?.name === '$script') {
50+
errorNode = nameNode
51+
}
52+
}
53+
}
54+
}
55+
},
56+
})
57+
if (errorNode) {
58+
return this.error(new Error('You can\'t use a top-level await on $script as it will never resolve.'))
8459
}
85-
}
60+
},
8661
}
87-
}
88-
}
89-
90-
async function extractScriptContentAst(code: string): Promise<AnyNode[] | undefined> {
91-
const scriptCode = code.match(SCRIPT_RE)
92-
return scriptCode
93-
? parse((await transform(scriptCode[1], { loader: 'ts' })).code, {
94-
ecmaVersion: 'latest',
95-
sourceType: 'module',
96-
}).body
97-
: undefined
98-
}
99-
100-
function extractSetupFunction(code: string): AnyNode[] | undefined {
101-
const ast = parse(code, {
102-
ecmaVersion: 'latest',
103-
sourceType: 'module',
10462
})
105-
106-
const defaultExport = ast.body.find((node): node is ExportDefaultDeclaration => node.type === 'ExportDefaultDeclaration')
107-
108-
if (defaultExport && defaultExport.declaration.type === 'CallExpression' && defaultExport.declaration.callee.type === 'Identifier' && defaultExport.declaration.callee.name === 'defineComponent') {
109-
const arg = defaultExport.declaration.arguments[0]
110-
if (arg && arg.type === 'ObjectExpression') {
111-
const setupProperty = arg.properties.find((prop): prop is Property => prop.type === 'Property' && prop.key.type === 'Identifier' && prop.key.name === 'setup')
112-
if (setupProperty) {
113-
const setupValue = setupProperty.value
114-
if (setupValue.type === 'FunctionExpression') {
115-
return setupValue.body.body
116-
}
117-
}
118-
}
119-
}
12063
}

src/plugins/transform.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
import { pathToFileURL } from 'node:url'
21
import { createUnplugin } from 'unplugin'
3-
import { parseQuery, parseURL } from 'ufo'
42
import MagicString from 'magic-string'
53
import type { SourceMapInput } from 'rollup'
64
import type { Node } from 'estree-walker'
75
import { walk } from 'estree-walker'
86
import type { Literal, ObjectExpression, Property, SimpleCallExpression } from 'estree'
97
import type { InferInput } from 'valibot'
8+
import { isJS, isVue } from './util'
109
import type { RegistryScript } from '#nuxt-scripts'
1110

1211
export interface AssetBundlerTransformerOptions {
@@ -22,21 +21,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
2221
name: 'nuxt:scripts:bundler-transformer',
2322

2423
transformInclude(id) {
25-
const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
26-
const { type } = parseQuery(search)
27-
28-
if (pathname.includes('node_modules/@unhead') || pathname.includes('node_modules/vueuse'))
29-
return false
30-
31-
// vue files
32-
if (pathname.endsWith('.vue') && (type === 'script' || !search))
33-
return true
34-
35-
// js files
36-
if (pathname.match(/\.((c|m)?j|t)sx?$/g))
37-
return true
38-
39-
return false
24+
return isVue(id, { type: ['template', 'script'] }) || isJS(id)
4025
},
4126

4227
async transform(code, id) {

src/plugins/util.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { pathToFileURL } from 'node:url'
2+
import { parseQuery, parseURL } from 'ufo'
3+
4+
// from nuxt core
5+
export function isVue(id: string, opts: { type?: Array<'template' | 'script' | 'style'> } = {}) {
6+
// Bare `.vue` file (in Vite)
7+
const { search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
8+
if (id.endsWith('.vue') && !search) {
9+
return true
10+
}
11+
12+
if (!search) {
13+
return false
14+
}
15+
16+
const query = parseQuery(search)
17+
18+
// Component async/lazy wrapper
19+
if (query.nuxt_component) {
20+
return false
21+
}
22+
23+
// Macro
24+
if (query.macro && (search === '?macro=true' || !opts.type || opts.type.includes('script'))) {
25+
return true
26+
}
27+
28+
// Non-Vue or Styles
29+
const type = 'setup' in query ? 'script' : query.type as 'script' | 'template' | 'style'
30+
if (!('vue' in query) || (opts.type && !opts.type.includes(type))) {
31+
return false
32+
}
33+
34+
// Query `?vue&type=template` (in Webpack or external template)
35+
return true
36+
}
37+
38+
const JS_RE = /\.(?:[cm]?j|t)sx?$/
39+
40+
export function isJS(id: string) {
41+
// JavaScript files
42+
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
43+
return JS_RE.test(pathname)
44+
}

0 commit comments

Comments
 (0)