UNPKG

live-plugin-manager

Version:

Install and uninstall any node package at runtime from npm registry

500 lines (404 loc) 14.5 kB
import * as vm from "vm"; import * as fs from "fs-extra"; import * as path from "path"; import * as console from "console"; import {PluginManager} from "./PluginManager"; import {IPluginInfo} from "./PluginInfo"; import Debug from "debug"; import { PluginSandbox } from "../index"; const debug = Debug("live-plugin-manager.PluginVm"); const SCOPED_REGEX = /^(@[a-zA-Z0-9-_]+\/[a-zA-Z0-9-_]+)(.*)/; type NodeJSGlobal = typeof global; export class PluginVm { private requireCache = new Map<IPluginInfo, Map<string, NodeModule>>(); private sandboxCache = new Map<IPluginInfo, NodeJSGlobal>(); constructor(private readonly manager: PluginManager) { } unload(pluginContext: IPluginInfo): void { this.requireCache.delete(pluginContext); this.sandboxCache.delete(pluginContext); } load(pluginContext: IPluginInfo, filePath: string): any { let moduleInstance = this.getCache(pluginContext, filePath); if (moduleInstance) { if (debug.enabled) { debug(`${filePath} loaded from cache`); } return moduleInstance.exports; } if (debug.enabled) { debug(`Loading ${filePath} ...`); } const sandbox = this.createModuleSandbox(pluginContext, filePath); moduleInstance = sandbox.module; const filePathExtension = path.extname(filePath).toLowerCase(); if (filePathExtension === ".js" || filePathExtension === ".cjs") { const code = fs.readFileSync(filePath, "utf8"); // note: I first put the object (before executing the script) in cache to support circular require this.setCache(pluginContext, filePath, moduleInstance); try { this.vmRunScriptInSandbox(sandbox, filePath, code); } catch (e) { // in case of error remove the cache this.removeCache(pluginContext, filePath); throw e; } } else if (filePathExtension === ".json") { sandbox.module.exports = fs.readJsonSync(filePath); this.setCache(pluginContext, filePath, moduleInstance); } else { throw new Error("Invalid javascript file " + filePath); } moduleInstance.loaded = true; return moduleInstance.exports; } resolve(pluginContext: IPluginInfo, filePath: string): string { return this.sandboxResolve(pluginContext, pluginContext.location, filePath); } runScript(code: string): any { const name = "dynamic-" + Date.now; const filePath = path.join(this.manager.options.pluginsPath, name + ".js"); const pluginContext: IPluginInfo = { location: path.join(this.manager.options.pluginsPath, name), mainFile: filePath, name, version: "1.0.0", dependencies: {}, dependencyDetails: {} }; try { return this.vmRunScriptInPlugin(pluginContext, filePath, code); } finally { this.unload(pluginContext); } } splitRequire(fullName: string) { const scopedInfo = this.getScopedInfo(fullName); if (scopedInfo) { return scopedInfo; } const slashPosition = fullName.indexOf("/"); let requiredPath: string | undefined; let pluginName = fullName; if (slashPosition > 0) { pluginName = fullName.substring(0, slashPosition); requiredPath = "." + fullName.substring(slashPosition); } return { pluginName, requiredPath }; } private getScopedInfo(fullName: string) { const match = SCOPED_REGEX.exec(fullName); if (!match) { return undefined; } const requiredPath = match[2] ? "." + match[2] : undefined; return { pluginName: match[1], requiredPath }; } private vmRunScriptInSandbox(moduleSandbox: ModuleSandbox, filePath: string, code: string): void { const moduleContext = vm.createContext(moduleSandbox); // For performance reasons wrap code in a Immediately-invoked function expression // https://60devs.com/executing-js-code-with-nodes-vm-module.html // I have also declared the exports variable to support the // `var app = exports = module.exports = {};` notation const iifeCode = ` (function(exports){ ${code} }(module.exports));`; const vmOptions = { displayErrors: true, filename: filePath }; const script = new vm.Script(iifeCode, vmOptions); script.runInContext(moduleContext, vmOptions); } private vmRunScriptInPlugin(pluginContext: IPluginInfo, filePath: string, code: string): any { const sandbox = this.createModuleSandbox(pluginContext, filePath); this.vmRunScriptInSandbox(sandbox, filePath, code); sandbox.module.loaded = true; return sandbox.module.exports; } private getCache(pluginContext: IPluginInfo, filePath: string): NodeModule | undefined { const moduleCache = this.requireCache.get(pluginContext); if (!moduleCache) { return undefined; } return moduleCache.get(filePath); } private setCache(pluginContext: IPluginInfo, filePath: string, instance: NodeModule): void { let moduleCache = this.requireCache.get(pluginContext); if (!moduleCache) { moduleCache = new Map<string, any>(); this.requireCache.set(pluginContext, moduleCache); } moduleCache.set(filePath, instance); } private removeCache(pluginContext: IPluginInfo, filePath: string): void { const moduleCache = this.requireCache.get(pluginContext); if (!moduleCache) { return; } moduleCache.delete(filePath); } private createModuleSandbox(pluginContext: IPluginInfo, filePath: string): ModuleSandbox { const pluginSandbox = this.getPluginSandbox(pluginContext); const moduleDirname = path.dirname(filePath); const moduleResolve: RequireResolve = Object.assign( (id: string) => { return this.sandboxResolve(pluginContext, moduleDirname, id); }, { paths: (_request: string) => null // TODO I should I populate this } ); const moduleRequire: NodeRequire = Object.assign( (requiredName: string) => { if (debug.enabled) { debug(`Requiring '${requiredName}' from ${filePath}...`); } return this.sandboxRequire(pluginContext, moduleDirname, requiredName); }, { resolve: moduleResolve, cache: {}, // TODO This should be correctly populated extensions: {} as NodeJS.RequireExtensions, main: require.main // TODO assign the real main or consider main the current module (ie. module)? } ); const myModule: NodeModule = { exports: {}, filename: filePath, id: filePath, loaded: false, require: moduleRequire, paths: [], // TODO I should I populate this parent: module, // TODO I assign parent to the current module...it is correct? children: [], // TODO I should populate correctly this list... path: moduleDirname, isPreloading: false }; // assign missing https://nodejs.org/api/globals.html // and other "not real global" objects const moduleSandbox: ModuleSandbox = { ...pluginSandbox, module: myModule, __dirname: moduleDirname, __filename: filePath, require: moduleRequire }; return moduleSandbox; } private sandboxResolve(pluginContext: IPluginInfo, moduleDirName: string, requiredName: string): string { // I try to use a similar logic of https://nodejs.org/api/modules.html#modules_modules // is a relative module or absolute path if (requiredName.startsWith(".") || path.isAbsolute(requiredName)) { const fullPath = path.resolve(moduleDirName, requiredName); // for security reason check to not load external files if (!fullPath.startsWith(pluginContext.location)) { throw new Error("Cannot require a module outside a plugin"); } const isFile = this.tryResolveAsFile(fullPath); if (isFile) { return isFile; } const isDirectory = this.tryResolveAsDirectory(fullPath); if (isDirectory) { return isDirectory; } throw new Error(`Cannot find ${requiredName} in plugin ${pluginContext.name}`); } if (this.hasDependency(pluginContext, requiredName)) { let fullPath = path.join(pluginContext.location, "node_modules", requiredName); if (!pluginContext.dependencyDetails) { throw new Error(`Dependencies not loaded for plugin ${pluginContext.name}`); } const packageJson = pluginContext.dependencyDetails[requiredName]; if (!packageJson) { throw new Error(`${pluginContext.name} does not include ${requiredName} in local dependencies`); } if (packageJson.main) { fullPath = path.join(fullPath, packageJson.main); } const isFile = this.tryResolveAsFile(fullPath); if (isFile) { return isFile; } const isDirectory = this.tryResolveAsDirectory(fullPath); if (isDirectory) { return isDirectory; } throw new Error(`Cannot find ${requiredName} in plugin ${pluginContext.name}`); } if (this.isPlugin(requiredName)) { return requiredName; } if (this.manager.options.staticDependencies[requiredName]) { return requiredName; } // this will fail if module is unknown if (this.isCoreModule(requiredName)) { return requiredName; } return requiredName; } private sandboxRequire(pluginContext: IPluginInfo, moduleDirName: string, requiredName: string) { // I try to use a similar logic of https://nodejs.org/api/modules.html#modules_modules const fullName = this.sandboxResolve(pluginContext, moduleDirName, requiredName); // is an absolute file or directory that can be loaded if (path.isAbsolute(fullName)) { if (debug.enabled) { debug(`Resolved ${requiredName} as file ${fullName}`); } return this.load(pluginContext, fullName); } if (this.manager.options.staticDependencies[requiredName]) { if (debug.enabled) { debug(`Resolved ${requiredName} as static dependency`); } return this.manager.options.staticDependencies[requiredName]; } if (this.isPlugin(requiredName)) { if (debug.enabled) { debug(`Resolved ${requiredName} as plugin`); } return this.manager.require(requiredName); } if (this.isCoreModule(requiredName)) { if (debug.enabled) { debug(`Resolved ${requiredName} as core module`); } return require(requiredName); // I use system require } if (this.manager.options.hostRequire) { if (debug.enabled) { debug(`Resolved ${requiredName} as host module`); } return this.manager.options.hostRequire(requiredName); } throw new Error(`Module ${requiredName} not found, failed to load plugin ${pluginContext.name}`); } private isCoreModule(requiredName: string): boolean { return this.manager.options.requireCoreModules && require.resolve(requiredName) === requiredName; } private isPlugin(requiredName: string): boolean { const { pluginName } = this.splitRequire(requiredName); return !!this.manager.getInfo(pluginName); } private hasDependency(pluginContext: IPluginInfo, requiredName: string) { const { dependencyDetails } = pluginContext; if (!dependencyDetails) { return false; } return !!dependencyDetails[requiredName]; } private tryResolveAsFile(fullPath: string): string | undefined { const parentPath = path.dirname(fullPath); if (checkPath(parentPath) !== "directory") { return undefined; } const reqPathKind = checkPath(fullPath); if (reqPathKind !== "file") { if (checkPath(fullPath + ".cjs") === "file") { return fullPath + ".cjs"; } if (checkPath(fullPath + ".js") === "file") { return fullPath + ".js"; } if (checkPath(fullPath + ".json") === "file") { return fullPath + ".json"; } return undefined; } if (reqPathKind === "file") { return fullPath; } return undefined; } private tryResolveAsDirectory(fullPath: string): string | undefined { if (checkPath(fullPath) !== "directory") { return undefined; } const indexCjs = path.join(fullPath, "index.cjs"); if (checkPath(indexCjs) === "file") { return indexCjs; } const indexJs = path.join(fullPath, "index.js"); if (checkPath(indexJs) === "file") { return indexJs; } const indexJson = path.join(fullPath, "index.json"); if (checkPath(indexJson) === "file") { return indexJson; } return undefined; } private getPluginSandbox(pluginContext: IPluginInfo): NodeJSGlobal { let pluginSandbox = this.sandboxCache.get(pluginContext); if (!pluginSandbox) { const srcSandboxTemplate = this.manager.getSandboxTemplate(pluginContext.name) || this.manager.options.sandbox; pluginSandbox = this.createGlobalSandbox(srcSandboxTemplate); this.sandboxCache.set(pluginContext, pluginSandbox); } return pluginSandbox; } private createGlobalSandbox(sandboxTemplate: PluginSandbox): NodeJSGlobal { const srcGlobal = sandboxTemplate.global || global; const sandbox: NodeJSGlobal = {...srcGlobal}; // copy properties that are not copied automatically (don't know why..) // https://stackoverflow.com/questions/59009214/some-properties-of-the-global-instance-are-not-copied-by-spread-operator-or-by-o // (some of these properties are Node.js specific, like Buffer) // Function and Object should not be defined, otherwise we will have some unexpected behavior // Somewhat related to https://github.com/nodejs/node/issues/28823 if (!sandbox.Buffer && srcGlobal.Buffer) { sandbox.Buffer = srcGlobal.Buffer; } if (!(sandbox as any).URL && global.URL) { // cast to any because URL is not defined inside NodeJSGlobal, I don't understand why ... (sandbox as any).URL = global.URL; } if (!(sandbox as any).URLSearchParams && global.URLSearchParams) { // cast to any because URLSearchParams is not defined inside NodeJSGlobal, I don't understand why ... (sandbox as any).URLSearchParams = global.URLSearchParams; } if (!sandbox.process && global.process) { sandbox.process = {...global.process}; } if (sandbox.process) { // override env to "unlink" from original process const srcEnv = sandboxTemplate.env || global.process.env; sandbox.process.env = {...srcEnv}; // copy properties (sandbox as any).process.on = (event: string, callback: any) => {}; } // create global console if (!sandbox.console) { sandbox.console = new console.Console({ stdout: process.stdout, stderr: process.stderr }); } // override the global obj to "unlink" it from the original global obj // and make it unique for each sandbox sandbox.global = sandbox; return sandbox; } } function checkPath(fullPath: string): "file" | "directory" | "none" { try { const stats = fs.statSync(fullPath); if (stats.isDirectory()) { return "directory"; } else if (stats.isFile()) { return "file"; } else { return "none"; } } catch { return "none"; } } interface ModuleSandbox extends NodeJSGlobal { module: NodeModule; __dirname: string; __filename: string; require: NodeRequire; }