// The safe node plugin is run inside of a worker // so if the plugin inevitably crashes, only the worker dies // this also lets plugins get terminated easier by killing the worker // rather than unloading and reloading code import Logger from '@/logger'; Logger.VERBOSE = process.env.VERBOSE === 'true'; import OmeggaPlugin, { OmeggaLike, PluginConfig, PluginInterop, PluginStore, } from '@/plugin'; import Player from '@omegga/player'; import type Omegga from '@omegga/server'; import { mkdir } from '@util/file'; import 'colors'; import EventEmitter from 'events'; import fs from 'fs'; import { cloneDeep } from 'lodash'; import path from 'path'; import { NodeVM } from 'vm2'; import webpack, { Stats } from 'webpack'; import { parentPort } from 'worker_threads'; import { ProxyOmegga } from './proxyOmegga'; const MAIN_FILE = 'omegga.plugin.js'; const MAIN_FILE_TS = 'omegga.plugin.ts'; let vm: NodeVM, PluginClass: { new ( omegga: OmeggaLike, config: PluginConfig, store: PluginStore ): OmeggaPlugin; }, pluginInstance: OmeggaPlugin; let pluginName = 'unnamed plugin'; let messageCounter = 0; // emitter that receives messages from the parent const parent = new EventEmitter(); // handle message passing parentPort.on('message', ({ action, args }) => parent.emit(action, ...args)); // emit a message to the parent port - async wait for a reponse const emit = (action: string, ...args: any[]) => { const messageId = 'message:' + messageCounter++; let rejectFn: (reason?: any) => void; // promise waits for the message to resolve const promise = new Promise((resolve, reject) => { parent.once(messageId, resolve); rejectFn = reject; }); try { // post the message parentPort.postMessage({ action, args: [messageId, ...args] }); } catch (err) { rejectFn(err); } // return the promise return promise; }; // tell omegga to exec a command const exec = (cmd: string) => emit('exec', cmd); // create the proxy omegga const omegga = new ProxyOmegga(exec); // add plugin fetcher omegga.getPlugin = async name => { let plugin = (await emit('getPlugin', name)) as PluginInterop & { emitPlugin(event: string, ...args: any[]): Promise<any>; }; if (plugin) { plugin.emitPlugin = async (ev: string, ...args: any[]) => { return await emit('emitPlugin', name, ev, cloneDeep(args)); }; return plugin; } else { return null; } }; // interface with plugin store const store: PluginStore = { get: <T>(key: string) => emit('store.get', key) as Promise<T>, set: <T>(key: string, value: T) => emit('store.set', key, JSON.stringify(value)) as Promise<void>, delete: (key: string) => emit('store.delete', key) as Promise<void>, wipe: () => emit('store.wipe') as Promise<void>, count: () => emit('store.count') as Promise<number>, keys: () => emit('store.keys') as Promise<string[]>, }; // generic brickadia events are forwarded to the proxy omegga parent.on('brickadiaEvent', (type, ...args) => { if (!vm) return; if (type === 'error') { Logger.errorp(pluginName.brightRed, 'Received error', ...args); return; } try { omegga.emit(type, ...args); } catch (e) { Logger.errorp( pluginName.brightRed, `Error in safe plugin worker's brickadiaEvent (${type}):`, e?.stack ?? e.toString() ); } }); // create the node vm async function createVm( pluginPath: string, { builtin = ['*'], external = true, isTypeScript = false } = {} ): Promise<[boolean, string]> { let pluginCode: string; if (isTypeScript) { try { const tsBuildPath = path.join(pluginPath, '.build'); const sourceFileName = path.join(pluginPath, MAIN_FILE_TS); const outputPath = path.join(tsBuildPath, 'plugin.js'); mkdir(tsBuildPath); mkdir(path.join(tsBuildPath, '.cache')); const stats = await new Promise<Stats>((resolve, reject) => { webpack( Object.freeze({ target: 'node', context: __dirname, mode: 'development', entry: sourceFileName, devtool: 'source-map', output: { iife: false, library: { type: 'commonjs', }, path: tsBuildPath, filename: 'plugin.js', }, resolve: { extensions: ['.ts', '.js', '.json'], alias: { // src is the only hard coded path src: path.resolve(pluginPath, 'src'), }, }, cache: { type: 'filesystem', cacheLocation: path.join(tsBuildPath, '.cache'), allowCollectingMemory: false, idleTimeout: 0, idleTimeoutForInitialStore: 0, // cache age is one week. plugins probably do not need // to be rebuilt every day maxAge: 1000 * 60 * 60 * 24 * 7, profile: true, }, module: { rules: [ { test: /\.[jt]s$/, exclude: /(node_modules)/, use: { loader: 'swc-loader', options: { sourceMaps: true, cwd: pluginPath, isModule: true, jsc: { target: 'es2020', parser: { syntax: 'typescript', }, transform: {}, }, module: { type: 'commonjs', strictMode: false, }, }, }, }, ], }, }), (err, stats) => (err ? reject(err) : resolve(stats)) ); }); if (stats.hasErrors()) { for (const err of stats.toJson().errors) { Logger.errorp(err.moduleName, err.file); Logger.errorp(err.message); } } if (stats.hasWarnings()) { for (const warning of stats.toJson().warnings) { Logger.warnp(warning.moduleName, warning.file); Logger.warnp(warning.message); } } pluginCode = fs.readFileSync(outputPath).toString(); } catch (err) { Logger.errorp(pluginName.brightRed, err); return [false, 'failed compiling building typescript']; } // update omegga.d.ts to latest on plugin compile try { const gitIgnore = path.join(pluginPath, '.gitignore'); const omeggaTypesDst = path.join(pluginPath, 'omegga.d.ts'); const omeggaTypesSrc = path.join( __dirname, '../../../../templates/safe-ts/omegga.d.ts' ); // plugin has gitignore with "omegga.d.ts" in it and omegga has omegga.d.ts if (fs.existsSync(gitIgnore) && fs.existsSync(omeggaTypesSrc)) { // and the gitignore covers the omegga.d.ts const hasOmeggaTypesIgnored = fs .readFileSync(gitIgnore) .toString() .match(/(\.\/)?omegga\.d\.ts/); // compare last modified times to avoid unnecessary copies const srcLastModified = fs.statSync(omeggaTypesSrc).mtime.getTime(); const dstLastModified = fs.existsSync(omeggaTypesDst) ? fs.statSync(omeggaTypesDst).mtime.getTime() : null; if ( hasOmeggaTypesIgnored && (!dstLastModified || srcLastModified > dstLastModified) ) { fs.copyFileSync(omeggaTypesSrc, omeggaTypesDst); } } } catch (err) { Logger.errorp( pluginName.brightRed, 'error copying latest omegga.d.ts to typescript plugin', err ); } } if (vm !== undefined) return [false, 'vm is already created']; // create the vm vm = new NodeVM({ console: 'redirect', sandbox: {}, require: { external, builtin, root: pluginPath, }, }); // plugin log generator function const ezLog = ( logFn: 'log' | 'error' | 'info' | 'debug' | 'warn' | 'trace', name: string, symbol: string ) => (...args: any[]) => console[logFn](name.underline, symbol, ...args); // special formatting for stdout vm.on('console.log', ezLog('log', pluginName, '>>'.green)); vm.on('console.error', ezLog('error', pluginName.brightRed, '!>'.red)); vm.on('console.info', ezLog('info', pluginName, '#>'.blue)); vm.on('console.debug', ezLog('debug', pluginName, '?>'.blue)); vm.on('console.warn', ezLog('warn', pluginName.brightYellow, ':>'.yellow)); vm.on('console.trace', ezLog('trace', pluginName, 'T>'.grey)); global.OMEGGA_UTIL = require('../../../util/index.js'); // pass in util functions vm.freeze(global.OMEGGA_UTIL, 'OMEGGA_UTIL'); vm.freeze(omegga, 'Omegga'); vm.freeze(Player, 'Player'); const file = path.join(pluginPath, MAIN_FILE); if (!isTypeScript) { try { pluginCode = fs.readFileSync(file).toString(); } catch (e) { emit( 'error', 'failed to read plugin source: ' + e?.stack ?? e.toString() ); throw 'failed to read plugin source: ' + e?.stack ?? e.toString(); } } // proxy the plugin out of the vm // potential for performance improvement by using VM.script to precompile plugins try { const pluginOutput = vm.run(pluginCode, file); PluginClass = pluginOutput?.default ?? pluginOutput; } catch (e) { emit('error', 'plugin failed to init'); Logger.errorp(pluginName.brightRed, e); throw 'plugin failed to init: ' + e?.stack ?? e.toString(); } if ( !PluginClass || typeof PluginClass !== 'function' || typeof PluginClass.prototype !== 'object' ) { PluginClass = undefined; emit('error', 'plugin does not export a class'); throw 'plugin does not export a class'; } if (typeof PluginClass.prototype.init !== 'function') { PluginClass = undefined; emit('error', 'plugin is missing init() function'); throw 'plugin is missing init() function'; } if (typeof PluginClass.prototype.stop !== 'function') { PluginClass = undefined; emit('error', 'plugin is missing stop() function'); throw 'plugin is missing stop() function'; } } // kill this plugin parent.on('kill', resp => { emit(resp); process.exit(0); }); // set plugin name parent.on('name', (resp, name) => { pluginName = name; // temp save prefix changes to avoid collision omegga._tempSavePrefix = 'omegga_' + name + '_temp'; emit(resp); }); // get memory usage for this plugin parent.on('mem', resp => emit(resp, 'mem', process.memoryUsage())); // create the vm parent.on('load', async (resp, pluginPath, options) => { try { await createVm(pluginPath, options); emit(resp, true); } catch (err) { Logger.errorp( pluginName.brightRed, 'error creating vm', err?.stack ?? err.toString() ); emit(resp, false); } }); // start the plugin with a faux omegga // resp is an action sent back to the parent process // to coordinate async funcs parent.on('start', async (resp, config) => { try { pluginInstance = new PluginClass(omegga as any as Omegga, config, store); const result = await pluginInstance.init(); // if a plugin init returns a list of strings, treat them as the list of commands if (typeof result === 'object' && result) { // if registeredCommands is in the results, register the provided strings as commands const cmds = result.registeredCommands; if ( cmds && cmds instanceof Array && cmds.every(i => typeof i === 'string') ) { emit('command.registers', JSON.stringify(cmds)); } } emit(resp, true); } catch (err) { emit('error', 'error starting plugin', err?.stack ?? JSON.stringify(err)); emit(resp, false); Logger.errorp(pluginName.brightRed, 'Error starting plugin', err); } }); // stop the plugin parent.on('stop', async resp => { try { if (pluginInstance) { await pluginInstance.stop.bind(pluginInstance)(); } pluginInstance = undefined; emit(resp, true); } catch (err) { emit('error', 'error stopping plugin', err?.stack ?? err.toString()); emit(resp, false); } }); // handle emitPlugins parent.on('emitPlugin', async (resp, ev, from, args) => { if (pluginInstance?.pluginEvent) { emit(resp, await pluginInstance.pluginEvent(ev, from, ...args)); } else { emit(resp, null); } });