diff --git a/index.html b/index.html index 3ab8f0dce..5a1a962d4 100644 --- a/index.html +++ b/index.html @@ -861,5 +861,5 @@
Loading Bitburner...
- + diff --git a/package.json b/package.json index 732d021c8..46fb81a93 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "supports-color": "^4.2.1", "tapable": "^0.2.7", "uglifyjs-webpack-plugin": "^0.4.6", + "uuid": "^3.2.1", "w3c-blob": "0.0.1", "watchpack": "^1.4.0", "webpack-sources": "^1.0.1", @@ -42,6 +43,8 @@ "beautify-lint": "^1.0.3", "benchmark": "^2.1.1", "bundle-loader": "~0.5.0", + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", "codacy-coverage": "^2.0.1", "codecov.io": "^0.1.2", "coffee-loader": "~0.7.1", @@ -79,6 +82,7 @@ "webpack": "^4.1.1", "webpack-cli": "^2.0.12", "webpack-dev-middleware": "^1.9.0", + "webpack-dev-server": "^3.1.4", "worker-loader": "^0.8.0" }, "engines": { @@ -107,6 +111,7 @@ "nsp": "nsp check --output summary", "pretest": "npm run lint-files", "publish-patch": "npm run lint && npm run beautify-lint && mocha && npm version patch && git push && git push --tags && npm publish", + "start:dev": "webpack-dev-server", "test": "mocha test/*.test.js --max-old-space-size=4096 --harmony --check-leaks", "travis:benchmark": "npm run benchmark", "travis:lint": "npm run lint-files && npm run nsp", diff --git a/src/NetscriptJSEvaluator.js b/src/NetscriptJSEvaluator.js new file mode 100644 index 000000000..9fa43d2ad --- /dev/null +++ b/src/NetscriptJSEvaluator.js @@ -0,0 +1,102 @@ +import {registerEnv, unregisterEnv, makeEnvHeader} from "./NetscriptJSPreamble.js"; +import {makeRuntimeRejectMsg} from "./NetscriptEvaluator.js"; + +// Makes a blob that contains the code of a given script. +export function makeScriptBlob(code) { + return new Blob([code], {type: "text/javascript"}); +} + +// Begin executing a user JS script, and return a promise that resolves +// or rejects when the script finishes. +// - script is a script to execute (see Script.js). We depend only on .filename and .code. +// scripts is an array of other scripts on the server. +// env is the global environment that should be visible to all the scripts +// (i.e. hack, grow, etc.). +// When the promise returned by this resolves, we'll have finished +// running the main function of the script. +export async function executeJSScript(script, scripts = [], env = {}) { + const envUuid = registerEnv(env); + const envHeader = makeEnvHeader(envUuid); + const urlStack = _getScriptUrls(script, scripts, envHeader, []); + + // The URL at the top is the one we want to import. It will + // recursively import all the other modules in the urlStack. + // + // Webpack likes to turn the import into a require, which sort of + // but not really behaves like import. Particularly, it cannot + // load fully dynamic content. So we hide the import from webpack + // by placing it inside an eval call. + try { + // TODO: putting await in a non-async function yields unhelpful + // "SyntaxError: unexpected reserved word" with no line number information. + const loadedModule = await eval('import(urlStack[urlStack.length - 1])'); + if (!loadedModule.main) { + throw makeRuntimeRejectMsg(script.filename + + " did not have a main function, cannot run it."); + } + return await loadedModule.main(); + } finally { + // Revoke the generated URLs and unregister the environment. + for (const url in urlStack) URL.revokeObjectURL(url); + unregisterEnv(envUuid); + }; +} + +// Gets a stack of blob urls, the top/right-most element being +// the blob url for the named script on the named server. +// +// - script -- the script for whom we are getting a URL. +// - scripts -- all the scripts available on this server +// - envHeader -- the preamble that goes at the start of every NSJS script. +// - seen -- The modules above this one -- to prevent mutual dependency. +// +// TODO We don't make any effort to cache a given module when it is imported at +// different parts of the tree. That hasn't presented any problem with during +// testing, but it might be an idea for the future. Would require a topo-sort +// then url-izing from leaf-most to root-most. +function _getScriptUrls(script, scripts, envHeader, seen) { + // Inspired by: https://stackoverflow.com/a/43834063/91401 + const urlStack = []; + seen.push(script); + try { + // Replace every import statement with an import to a blob url containing + // the corresponding script. E.g. + // + // import {foo} from "bar.js"; + // + // becomes + // + // import {foo} from "blob://" + // + // Where the blob URL contains the script content. + const transformedCode = script.code.replace(/((?:from|import)\s+(?:'|"))([^'"]+)('|";)/g, + (unmodified, prefix, filename, suffix) => { + const isAllowedImport = scripts.some(s => s.filename == filename); + if (!isAllowedImport) return unmodified; + + // Find the corresponding script. + const [importedScript] = scripts.filter(s => s.filename == filename); + + // Try to get a URL for the requested script and its dependencies. + const urls = _getScriptUrls(importedScript, scripts, envHeader, seen); + + // The top url in the stack is the replacement import file for this script. + urlStack.push(...urls); + return [prefix, urls[urls.length - 1], suffix].join(''); + }); + + // Inject the NSJS preamble at the top of the code. + const transformedCodeWithHeader = [envHeader, transformedCode].join("\n"); + + // If we successfully transformed the code, create a blob url for it and + // push that URL onto the top of the stack. + urlStack.push(URL.createObjectURL(makeScriptBlob(transformedCodeWithHeader))); + return urlStack; + } catch (err) { + // If there is an error, we need to clean up the URLs. + for (const url in urlStack) URL.revokeObjectURL(url); + throw err; + } finally { + seen.pop(); + } +} diff --git a/src/NetscriptJSPreamble.js b/src/NetscriptJSPreamble.js new file mode 100644 index 000000000..2fd35b6dc --- /dev/null +++ b/src/NetscriptJSPreamble.js @@ -0,0 +1,42 @@ +// A utility function that adds a preamble to each Netscript JS +// script. This preamble will set all the global functions and +// variables appropriately for the module. +// +// One caveat is that we don't allow the variables in the preable +// to change. Unlike in normal Javascript, this would not change +// properties of self. It would instead just change the variable +// within the given module -- not good! Users should not really +// need to do this anyway. + +import uuidv4 from "uuid/v4"; +import {sprintf} from "sprintf-js"; + +window.__NSJS__environments = {}; + +// Returns the UUID for the env. +export function registerEnv(env) { + const uuid = uuidv4(); + window.__NSJS__environments[uuid] = env; + return uuid; +} + +export function unregisterEnv(uuid) { + delete window.__NSJS__environments[uuid]; +} + +export function makeEnvHeader(uuid) { + if (!(uuid in window.__NSJS__environments)) throw new Error("uuid is not in the environment" + uuid); + + const env = window.__NSJS__environments[uuid]; + var envLines = []; + for (const prop in env) { + envLines.push("const ", prop, " = ", "__NSJS_ENV[\"", prop, "\"];\n"); + } + + return sprintf(` + 'use strict'; + const __NSJS_ENV = window.__NSJS__environments['%s']; + // The global variable assignments (hack, weaken, etc.). + %s + `, uuid, envLines.join("")); +} \ No newline at end of file diff --git a/tests/NetscriptJSTest.js b/tests/NetscriptJSTest.js new file mode 100644 index 000000000..32a9af919 --- /dev/null +++ b/tests/NetscriptJSTest.js @@ -0,0 +1,49 @@ +import {executeJSScript} from "../src/NetscriptJSEvaluator.js"; + +const chai = require("chai"); +const chaiAsPromised = require("chai-as-promised"); +chai.should(); +chai.use(chaiAsPromised); + +console.info('asdf'); + +describe('NSJS ScriptStore', function() { + it('should run an imported function', async function() { + const s = { filename: "", code: "export function main() { return 2; }" }; + chai.expect(await executeJSScript(s)).to.equal(2); + }); + + it('should handle recursive imports', async function() { + const s1 = { filename: "s1.js", code: "export function iAmRecursiveImport(x) { return x + 2; }" }; + const s2 = { filename: "", code: ` + import {iAmRecursiveImport} from \"s1.js\"; + export function main() { return iAmRecursiveImport(3); + }`}; + chai.expect(await executeJSScript(s2, [s1, s2])).to.equal(5); + }); + + it (`should correctly reference the passed global env`, async function() { + var [x, y] = [0, 0]; + var env = { + updateX: function(value) { x = value; }, + updateY: function(value) { y = value; }, + }; + const s1 = {filename: "s1.js", code: "export function importedFn(x) { updateX(x); }"}; + const s2 = {filename: "s2.js", code: ` + import {importedFn} from "s1.js"; + export function main() { updateY(7); importedFn(3); } + `} + await executeJSScript(s2, [s1, s2], env); + chai.expect(y).to.equal(7); + chai.expect(x).to.equal(3); + }); + + it (`should throw on circular dep`, async function() { + const s1 = {filename: "s1.js", code: "import \"s2.js\""}; + const s2 = {filename: "s2.js", code: ` + import * as s1 from "s1.js"; + export function main() {} + `} + executeJSScript(s2, [s1, s2]).should.eventually.throw(); + }); +}); \ No newline at end of file diff --git a/tests/index.html b/tests/index.html new file mode 100644 index 000000000..012f292ad --- /dev/null +++ b/tests/index.html @@ -0,0 +1,20 @@ + + + + + Mocha Tests + + + +
+ + + + + + + + diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 000000000..87313789b --- /dev/null +++ b/tests/index.js @@ -0,0 +1,3 @@ +//require("babel-core/register"); +//require("babel-polyfill"); +module.exports = require("./NetscriptJSTest.js"); diff --git a/webpack.config.js b/webpack.config.js index 933866181..9c604ae4e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -15,11 +15,14 @@ module.exports = { }), ], target: "web", - entry: "./src/engine.js", + entry: { + engine: "./src/engine.js", + tests: "./tests/index.js", + }, devtool: "nosources-source-map", output: { path: path.resolve(__dirname, "dist"), - filename: "bundle.js", + filename: "[name].bundle.js", devtoolModuleFilenameTemplate: "[id]" }, module: { @@ -44,5 +47,8 @@ module.exports = { namedChunks: false, minimize: false, portableRecords: true + }, + devServer: { + publicPath: "/dist", } };