import vm from "vm"; import * as acorn from "acorn"; import * as walk from "acorn-walk"; import { ImportTransformer } from "esm-import-transformer"; import { createRequire, Module } from "module"; import { getWorkingDirectory } from "./util/getWorkingDirectory.js"; import { isSupported } from "./util/vmModules.js"; const IS_VM_MODULES_SUPPORTED = isSupported(); // `import` and `require` should both be relative to working directory (not this file) const WORKING_DIRECTORY = getWorkingDirectory(); // TODO (feature) option to change `require` home base const customRequire = createRequire(WORKING_DIRECTORY); class RetrieveGlobals { constructor(code, options) { this.originalCode = code; // backwards compat if(typeof options === "string") { options = { filePath: options }; } this.options = Object.assign({ filePath: null, transformEsmImports: false, }, options); if(IS_VM_MODULES_SUPPORTED) { // Override: no code transformations if vm.Module works this.options.transformEsmImports = false; } // set defaults let acornOptions = {}; if(IS_VM_MODULES_SUPPORTED || this.options.transformEsmImports) { acornOptions.sourceType = "module"; } this.setAcornOptions(acornOptions); this.setCreateContextOptions(); // transform `import ___ from ___` to `const ___ = await import(___)` to emulate *some* import syntax. // Doesn’t currently work with aliases (mod as name) or namespaced imports (* as name). if(this.options.transformEsmImports) { this.code = this.transformer.transformToDynamicImport(); } else { this.code = this.originalCode; } } get transformer() { if(!this._transformer) { this._transformer = new ImportTransformer(this.originalCode); } return this._transformer; } setAcornOptions(acornOptions) { this.acornOptions = Object.assign({ ecmaVersion: "latest", }, acornOptions ); } setCreateContextOptions(contextOptions) { this.createContextOptions = Object.assign({ codeGeneration: { strings: false, wasm: false, } }, contextOptions ); } static _getProxiedContext(context = {}, options = {}) { return new Proxy(context, { get(target, propertyName) { if(Reflect.has(target, propertyName)) { return Reflect.get(target, propertyName); } if(options.reuseGlobal && Reflect.has(global, propertyName)) { return global[propertyName]; } if(options.addRequire && propertyName === "require") { return customRequire; } } }); } // We prune function and variable declarations that aren’t globally declared // (our acorn walker could be improved to skip non-global declarations, but this method is easier for now) static _getGlobalVariablesReturnString(names, mode = "cjs") { let s = [`let __globals = {};`]; for(let name of names) { s.push(`if( typeof ${name} !== "undefined") { __globals.${name} = ${name}; }`); } return `${s.join("\n")};${mode === "esm" ? "\nexport default __globals;" : "return __globals;"}` } _setContextPrototype(context) { // Context will fail isPlainObject and won’t be merged in the data cascade properly without this prototype set // See https://github.com/11ty/eleventy-utils/blob/main/src/IsPlainObject.js if(!context || typeof context !== "object" || Array.isArray(context)) { return; } if(!Object.getPrototypeOf(context).isPrototypeOf(Object.create({}))) { Object.setPrototypeOf(context, Object.prototype); // Go deep for(let key in context) { this._setContextPrototype(context[key]); } } } _getCode(code, options) { let { async: isAsync, globalNames, experimentalModuleApi, data } = Object.assign({ async: true }, options); if(IS_VM_MODULES_SUPPORTED) { return `${code} ${globalNames ? RetrieveGlobals._getGlobalVariablesReturnString(globalNames, "esm") : ""}`; } let prefix = []; let argKeys = ""; let argValues = ""; // Don’t use this when vm.Module is stable (or if the code doesn’t have any imports!) if(experimentalModuleApi) { prefix = "module.exports = "; if(typeof data === "object") { let dataKeys = Object.keys(data); if(dataKeys) { argKeys = `{${dataKeys.join(",")}}`; argValues = JSON.stringify(data, function replacer(key, value) { if(typeof value === "function") { throw new Error(`When using \`experimentalModuleApi\`, context data must be JSON.stringify friendly. The "${key}" property was type \`function\`.`); } return value; }); } } } return `${prefix}(${isAsync ? "async " : ""}function(${argKeys}) { ${code} ${globalNames ? RetrieveGlobals._getGlobalVariablesReturnString(globalNames, "cjs") : ""} })(${argValues});`; } getGlobalNames(parsedAst) { let globalNames = new Set(); let types = { FunctionDeclaration(node) { globalNames.add(node.id.name); }, VariableDeclarator(node) { // destructuring assignment Array if(node.id.type === "ArrayPattern") { for(let prop of node.id.elements) { if(prop.type === "Identifier") { globalNames.add(prop.name); } } } else if(node.id.type === "ObjectPattern") { // destructuring assignment Object for(let prop of node.id.properties) { if(prop.type === "Property") { globalNames.add(prop.value.name); } } } else if(node.id.name) { globalNames.add(node.id.name); } }, // if imports aren’t being transformed to variables assignment, we need those too ImportSpecifier(node) { globalNames.add(node.imported.name); } }; walk.simple(parsedAst, types); return globalNames; } _getParseError(code, err) { // Acorn parsing error on script let metadata = []; if(this.options.filePath) { metadata.push(`file: ${this.options.filePath}`); } if(err?.loc?.line) { metadata.push(`line: ${err.loc.line}`); } if(err?.loc?.column) { metadata.push(`column: ${err.loc.column}`); } return new Error(`Had trouble parsing with "acorn"${metadata.length ? ` (${metadata.join(", ")})` : ""}: Message: ${err.message} ${code}`); } async _getGlobalContext(data, options) { let { async: isAsync, reuseGlobal, dynamicImport, addRequire, experimentalModuleApi, } = Object.assign({ // defaults async: true, reuseGlobal: false, // adds support for `require` addRequire: false, // allows dynamic import in `vm` (requires --experimental-vm-modules in Node v20.10+) // https://github.com/nodejs/node/issues/51154 // TODO Another workaround possibility: We could use `import` outside of `vm` and inject the dependencies into context `data` dynamicImport: false, // Use Module._compile instead of vm // Workaround for: https://github.com/zachleat/node-retrieve-globals/issues/2 // Warning: This method requires input `data` to be JSON stringify friendly. // Don’t use this if vm.Module is supported // Don’t use this if the code does not contain `import`s experimentalModuleApi: !IS_VM_MODULES_SUPPORTED && this.transformer.hasImports(), }, options); if(IS_VM_MODULES_SUPPORTED) { // Override: don’t use this when modules are allowed. experimentalModuleApi = false; } // These options are already supported by Module._compile if(experimentalModuleApi) { addRequire = false; dynamicImport = false; } if(reuseGlobal || addRequire) { // Re-use the parent `global` https://nodejs.org/api/globals.html data = RetrieveGlobals._getProxiedContext(data || {}, { reuseGlobal, addRequire, }); } if(!data) { data = {}; } let context; if(experimentalModuleApi || vm.isContext(data)) { context = data; } else { context = vm.createContext(data, this.createContextOptions); } let parseCode; let globalNames; try { parseCode = this._getCode(this.code, { async: isAsync, }); let parsedAst = acorn.parse(parseCode, this.acornOptions); globalNames = this.getGlobalNames(parsedAst); } catch(e) { throw this._getParseError(parseCode, e); } try { let execCode = this._getCode(this.code, { async: isAsync, globalNames, experimentalModuleApi, data: context, }); if(experimentalModuleApi) { let m = new Module(); m._compile(execCode, WORKING_DIRECTORY); return m.exports; } let execOptions = {}; if(dynamicImport) { // Warning: this option is part of the experimental modules API execOptions.importModuleDynamically = (specifier) => import(specifier); } if(IS_VM_MODULES_SUPPORTED) { // options.initializeImportMeta let m = new vm.SourceTextModule(execCode, { context, initializeImportMeta: (meta, module) => { meta.url = this.options.filePath || WORKING_DIRECTORY || module.identifier; }, ...execOptions, }); // Thank you! https://stackoverflow.com/a/73282303/16711 await m.link(async (specifier, referencingModule) => { const mod = await import(specifier); const exportNames = Object.keys(mod); return new vm.SyntheticModule( exportNames, function () { exportNames.forEach(key => { this.setExport(key, mod[key]) }); }, { identifier: specifier, context: referencingModule.context } ); }); await m.evaluate(); // TODO (feature) incorporate other esm `exports` here return m.namespace.default; } return vm.runInContext(execCode, context, execOptions); } catch(e) { let type = "cjs"; if(IS_VM_MODULES_SUPPORTED) { type = "esm"; } else if(experimentalModuleApi) { type = "cjs-experimental"; } throw new Error(`Had trouble executing Node script (type: ${type}): Message: ${e.message} ${this.code}`); } } async getGlobalContext(data, options) { let ret = await this._getGlobalContext(data, Object.assign({ // whether or not the target code is executed asynchronously // note that vm.Module will always be async-friendly async: true, }, options)); this._setContextPrototype(ret); return ret; } } export { RetrieveGlobals };