UNPKG

starblast-modding

Version:

A powerful library for interacting with the Starblast Modding API

445 lines (368 loc) 14.3 kB
const fs1 = require('fs'); const fs = fs1.promises; const ModdingEvents = require("../resources/Events.js"); const ModdingClient = require("./ModdingClient.js"); const GameCode = fs1.readFileSync(__dirname + "/../utils/GameCode.js", "utf8"); const URLFetcher = require("../utils/URLFetcher.js"); const toString = require("../utils/toString.js"); const NodeVM = require("node:vm"); const { decode } = require("html-entities"); const required_codes = { "core-js": fs1.readFileSync(require.resolve("core-js-bundle/minified.js"), "utf8"), "xhr": fs1.readFileSync(require.resolve("xmlhttprequest-ssl/lib/XMLHttpRequest.js"), "utf8"), "fetch": fs1.readFileSync(require.resolve("whatwg-fetch/fetch.js"), "utf8") } /** * The Browser Client Instance for supporting mod codes running in Browser Modding. <br><b>Warning: </b><br><ul><li>This client doesn't support undocumented features like accessing through `game.modding`, etc. </li><li>Some of the latest features of the new ModdingClient (which may not work in browsers) will be available. </li><li>Using Promise-related functionalities (including async/await) in your mod code is highly DISCOURAGED since NodeJS VM doesn't work well with Promise, and will likely crash or hang the running mod.</li> * @param {object} options - options for calling the object. <br><b>Note that</b> if both one property and its aliases exist on the object, the value of the main one will be chosen * @param {boolean} [options.cacheECPKey = false] - same with option specified at {@link ModdingClient} * @param {boolean} [options.extendedMode = false] - same with option specified at {@link ModdingClient} * @param {boolean} [options.strictMode = false] - Commands that affect the instance configuration (e.g `region`) won't be allowed to execute * @param {boolean} [options.persistentContext = true] - context where mod and command is executing on will be persistent across mod runs * @param {boolean} [options.sameCodeExecution = false] - loading the same code will trigger the execution or not. <br><b>Note:</b> This feature only works when you call `loadCodeFromString`, `loadCodeFromLocal` or `loadCodeFromExternal` methods, and not during the auto-update process * @param {boolean} [options.crashOnException = false] - when tick or event function, or mod code execution fails, the mod will crash * @param {boolean} options.crashOnError - alias of the property `options.crashOnException` * @param {boolean} [options.logErrors = true] - game will log any errors or not * @param {boolean} options.logExceptions - alias of the property `options.logErrors` * @param {boolean} [options.logMessages = true] - game will log any in-game logs or not * @param {boolean} [options.compressWSMessages = false] - same with option specified at {@link ModdingClient} * @param {boolean} [options.disableNetworkRequests = false] - disable network requests (e.g. `fetch`, `XMLHttpRequest`, etc.) or not * @since 1.1.0-alpha6 */ class BrowserClient { constructor(options) { this.#sameCodeExecution = !!options?.sameCodeExecution; this.#disableNetworkRequests = !!options?.disableNetworkRequests; this.#strictMode = !!options?.strictMode; let logErrors = this.#logErrors = !!(options?.logErrors ?? options.logExceptions ?? true); let logMessages = this.#logMessages = !!(options?.logMessages ?? true); this.#persistentContext = !!(options?.persistentContext ?? true); let crashOnError = this.#crashOnError = !!(options?.crashOnException ?? options?.crashOnError); let node = this.#node = new ModdingClient({...options, cacheEvents: true, cacheOptions: false}); this.resetContext(); // events if (!crashOnError) node.on(ModdingEvents.ERROR, function(error) { if (logErrors) console.error("[In-game Error]", error) }); node.on(ModdingEvents.LOG, function(...args) { if (logMessages) console.log("[In-game Log]", ...args) }); node.on(ModdingEvents.MOD_STOPPED, () => { this.#clearWatch(); this.#lastCode = null; }); for (let event of Object.values(ModdingEvents)) { node.on(event, (...args) => { // errors should not be thrown on this since the game code is carefully handled // if any errors have stack trace on this part, please contact author (@bhpsngum) this.#listeners[event].call(this.#modding.context, ...args); }); } } #vmContext; #contextBridge; #modding; #listeners = Object.create(null); #timer_pool = { id: 0, data: new Map(), add: function (timer, interval) { this.data.set(++this.id, { timer, interval }); return this.id; }, remove: function (timerID, noRemove = false) { let timer = this.data.get(timerID); if (!timer) return; if (!noRemove) this.manualRemove(timer); this.data.delete(timerID); }, manualRemove: function (timer) { if (timer.interval) clearInterval(timer.timer); else clearTimeout(timer.timer); }, reset: function () { for (let timer of this.data.values()) this.manualRemove(timer); this.id = 0; this.data.clear(); } } #remoteLog (e) { if ("function" === typeof this.#messageHandler) try { this.#messageHandler({ content: decode(e.content), raw: e.raw, type: e.type }); } finally {} else this.#node[e.type](decode(e.content)); } /** * Set the region of the client. * @param {string} regionName - region name, must be either Asia, America or Europe * @returns {BrowserClient} */ setRegion (...data) { this.#node.setRegion(...data); return this } /** * Destroy and recreate the context where mod and command execution is running on. * @since 1.4.14-alpha6 */ resetContext () { if (this.#node.processStarted) throw new Error("Context cannot be reset because mod/process is currently running."); this.#timer_pool.reset(); this.#listeners = Object.create(null); for (let event of Object.values(ModdingEvents)) { this.#listeners[event] = () => {} } let compile = (code, timeout) => { return this.#vmExec(code, timeout); } // apply a new environment this.#vmContext = NodeVM.createContext(Object.assign(Object.create(null), { require, required_codes, disableNetwork: this.#disableNetworkRequests, parentGlobal: globalThis, timer_pool: this.#timer_pool, node: this.#node, ModdingEvents, strictMode: this.#strictMode, remoteCompile: async (code) => { return await compile(`(${Function("game", code).toString()}).call(this.game.modding.context, this.game)`); }, registerEvent: (event, listener) => void (this.#listeners[event] = listener), remoteLog: (e) => { this.#remoteLog(e); }, compile, getValue: async () => { if (this.#lastCode == null) await this.#applyChanges(true, false); return this.#lastCode; } }), { name: "Mod Context (BrowserClient VM)" }); Object.defineProperty(this.#vmContext, 'window', { enumerable: true, configurable: false, get: function window () { return this } }); // run setup script and get required parameters this.#contextBridge = this.#vmExec(GameCode, 5000); this.#modding = this.#contextBridge.modding; } /** * Set the ECP key that client will use for requests * @param {string} ECPKey - The ECP key * @returns {BrowserClient} */ setECPKey (...data) { this.#node.setECPKey(...data); return this } /** * Get the ModdingClient object running behind the scene * @returns {ModdingClient} */ getNode () { return this.#node; } /** * Get the game object, which acts the same as the `game` object in browser * @returns {object} */ getGame () { return this.#modding?.game; } #path; #URL; #code; #lastCode = null; #watchChanges = false; #watchInterval = 5000; #watchIntervalID = null; #assignedWatch = false; #executionTimeout; #node; #game; #sameCodeExecution; #crashOnError; #strictMode; #logErrors; #logMessages; #disableNetworkRequests = false; #persistentContext; #clearWatch () { clearInterval(this.#watchIntervalID); this.#assignedWatch = false; } #startWatch () { let watchFunc = async () => { await this.#applyChanges(); this.#watchIntervalID = setTimeout(watchFunc, this.#watchInterval); }; this.#watchIntervalID = setTimeout(watchFunc, this.#watchInterval); } #setWatchInterval (watchChanges, interval, timeout) { this.#clearWatch(); this.#assignedWatch = false; this.#watchChanges = !!watchChanges; if (this.#watchChanges) this.#watchInterval = Math.max(1, Math.floor(interval ?? 5000)) || 5000; this.#executionTimeout = timeout; return this } /** * Load the mod code from a script string * @param {string} text - The code string to execute * @param {Object} options - execution options * @param {number?} [options.executionTimeout = Infinity] - The timeout for executing this code * @returns {BrowserClient} */ async loadCodeFromString (text, options) { this.#path = null; this.#URL = null; this.#code = toString(text); this.#setWatchInterval(false, null, options?.executionTimeout); if (this.#node.processStarted) await this.#applyChanges(true); return this } /** * Load the mod code from a local file (File on your device) * @param {string} path - The path to the local file * @param {Object} options - execution options * @param {boolean} [options.watchChanges = false] - Whether to watch for changes on the file or not * @param {number} [options.watchInterval = 5000] - The interval between watches (if `watchChanges` is set to `true`) * @param {number?} [options.executionTimeout = Infinity] - The timeout for executing this code * @returns {BrowserClient} */ async loadCodeFromLocal (path, options) { this.#path = path; this.#URL = null; this.#code = null; this.#setWatchInterval(options?.watchChanges, options?.watchInterval, options?.executionTimeout); if (this.#node.processStarted) await this.#applyChanges(true); return this } /** * Load the mod code from an external URL file * @param {string} URL - The URL to the file * @param {Object} options - execution options * @param {boolean} [options.watchChanges = false] - Whether to watch for changes on the file or not * @param {number} [options.watchInterval = 5000] - The interval between watches (if `watchChanges` is set to `true`) * @param {number?} [options.executionTimeout = Infinity] - The timeout for executing this code * @returns {BrowserClient} */ async loadCodeFromExternal (URL, options) { this.#path = null; this.#URL = URL; this.#code = null; this.#setWatchInterval(options?.watchChanges, options?.watchInterval, options?.executionTimeout); if (this.#node.processStarted) await this.#applyChanges(true); return this } #messageHandler = null; /** * Message handler function * @name abstract_message_handler * @function * @param {Object} data Message data * @param {"error" | "log"} data.type Type of message * @param {String} data.content Parsed content of the message * @param {String} data.raw Raw content of the message */ /** * Poll messages (logs and errors) from browser client. * Defaults to logging parsed content/errors if no handler is present. * @param {abstract_message_handler} handler Message handler */ pollMessages (handler) { this.#messageHandler = handler; } #fromLocal () { return fs.readFile(this.#path, 'utf-8') } #fromExternal () { return URLFetcher(this.#URL) } async #applyChanges (forced, exec) { try { let lastCode = this.#lastCode; this.#lastCode = this.#URL ? (await this.#fromExternal()) : (this.#path ? (await this.#fromLocal()) : this.#code); if (this.#watchChanges && (this.#URL != null || this.#path != null) && !this.#assignedWatch) { this.#clearWatch(); this.#startWatch(); this.#assignedWatch = true; } let sameCode = this.#lastCode == lastCode; if (!sameCode || (forced && this.#sameCodeExecution)) { if (!this.#node.processStarted) { if (!this.#persistentContext) this.resetContext(); } await this.#contextBridge.setCode(exec ?? this.#node.processStarted); } } catch (e) { this.#node.error(e); } } #vmExec (code, timeout) { return new NodeVM.Script(code, { filename: "VM.BrowserClient_ModContext", importModuleDynamically: async function (moduleName) { throw new Error("Module import is not supported."); } }).runInContext(this.#vmContext, { timeout: arguments.length > 1 ? timeout : this.#executionTimeout, displayErrors: true }); } /** * Starts the game * @returns {string} Link of the game */ async start () { if (this.#node.processStarted) throw new Error("Mod already running, use stop first"); try { await this.#modding.run(); } catch (e) { this.#clearWatch(); this.#lastCode = null; throw e; } } /** * Executes any terminal command on current running instance. * @param {string} command - Command to execute * @param {object} options - Options for this execution * @param {boolean} [options.allowEval = false] - Whether to allow eval the command as JavaScript or not.<br> WARNING: THIS MAY CAUSE SECURITY ISSUES TO YOUR INSTANCE * @param {boolean} [options.captureOutput = false] - Whether to capture execution output or pipe it to error/log events instead * @param {number?} [options.executionTimeout] - Timeout for this execution, set to nullish to use default execution timeout from mod compilation * @returns {({ success: boolean, output: any })} Success status (boolean) and output of given execution (if ouput capturing is enabled) * @since 1.4.7-alpha6 */ async execute (command, options) { try { let output = await this.#contextBridge.execute(toString(command).replace(/^\s*\[\[([^]*)\]\]\s*$/, "$1"), options?.allowEval, options?.executionTimeout); if (options?.captureOutput) return { success: true, output }; if (output !== undefined && output !== "") this.#contextBridge.echo(output); return { success: true }; } catch (e) { if (options?.captureOutput) return { success: false, output: e }; this.#contextBridge.error(e); return { success: false }; } } /** * Stops the game * @returns {BrowserClient} */ async stop () { await this.#node.stop(); return this } } module.exports = BrowserClient