Skip to content

Commit

Permalink
Replace node vm with worker_threads
Browse files Browse the repository at this point in the history
  • Loading branch information
appurva21 committed May 2, 2024
1 parent e3ab7ea commit 9c9d5d4
Show file tree
Hide file tree
Showing 20 changed files with 302 additions and 553 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.yaml
@@ -1,4 +1,6 @@
unreleased:
new feature:
- Replaced Node VM with Worker threads
chores:
- Add GitHub CI and remove Travis

Expand Down
2 changes: 1 addition & 1 deletion README.md
@@ -1,6 +1,6 @@
# UVM [![CI](https://github.com/postmanlabs/uvm/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/postmanlabs/uvm/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/postmanlabs/uvm/branch/develop/graph/badge.svg)](https://codecov.io/gh/postmanlabs/uvm)

Module that exposes an event emitter to send data across contexts ([VM](https://nodejs.org/api/vm.html) in Node.js and [Web Workers](https://www.w3.org/TR/workers/) in browser).
Module that exposes an event emitter to send data across contexts ([Worker threads](https://nodejs.org/api/worker_threads.html) in Node.js and [Web Workers](https://www.w3.org/TR/workers/) in browser).

## Installation
UVM can be installed using NPM or directly from the git repository within your NodeJS projects. If installing from NPM, the following command installs the module and saves in your `package.json`
Expand Down
11 changes: 11 additions & 0 deletions firmware/sandbox-base.browser.js
@@ -0,0 +1,11 @@
module.exports = `
(function (self) {
var init = function (e) {
self.removeEventListener('message', init);
const __init_uvm = e && (e.__init_uvm || (e.data && e.data.__init_uvm));
// eslint-disable-next-line no-eval
(typeof __init_uvm === 'string') && eval(__init_uvm);
};
self.addEventListener('message', init);
}(self));
`;
11 changes: 5 additions & 6 deletions firmware/sandbox-base.js
@@ -1,10 +1,9 @@
module.exports = `
(function (self) {
var init = function (e) {
self.removeEventListener('message', init);
(function (parentPort) {
var init = function (m) {
// eslint-disable-next-line no-eval
(e && e.data && (typeof e.data.__init_uvm === 'string')) && eval(e.data.__init_uvm);
m && m.__init_uvm && (typeof m.__init_uvm === 'string') && eval(m.__init_uvm);
};
self.addEventListener('message', init);
}(self));
parentPort.once('message', init);
}(require('worker_threads').parentPort));
`;
10 changes: 7 additions & 3 deletions lib/bridge-client.js
Expand Up @@ -11,7 +11,7 @@
const toString = String.prototype.toString;

/**
* Generate code to be executed inside a VM for bootstrap.
* Generate code to be executed inside a worker for bootstrap.
*
* @param {String|Buffer} bootCode
* @return {String}
Expand All @@ -31,11 +31,15 @@ module.exports = function (bootCode) {
emit: function (name) {
var self = this,
args = arrayProtoSlice.call(arguments, 1);
this._events[name] && [...this._events[name]].forEach(function (listener) {
[...this.listeners(name)].forEach(function (listener) {
listener.apply(self, args);
});
},
listeners: function (name) {
return this._events[name] || [];
},
dispatch: function () {
emit(Flatted.stringify(arrayProtoSlice.call(arguments)));
},
Expand All @@ -55,7 +59,7 @@ module.exports = function (bootCode) {
},
off: function (name, listener) {
var e = this._events[name],
var e = this.listeners(name),
i = e && e.length || 0;
if (!e) { return; }
Expand Down
59 changes: 45 additions & 14 deletions lib/bridge.browser.js
@@ -1,36 +1,58 @@
/* istanbul ignore file */
/*
* @note options.dispatchTimeout is not implemented in browser sandbox because
* there is no way to interrupt an infinite loop.
* Maybe terminate and restart the worker or execute in nested worker.
*/
const Flatted = require('flatted'),
{ randomNumber } = require('./utils'),

// code for bridge
bridgeClientCode = require('./bridge-client'),

ERROR = 'error',
MESSAGE = 'message',
UVM_ID_ = '__id_uvm_',

// code for bridge
bridgeClientCode = require('./bridge-client'),
MESSAGE_ERROR = 'messageerror',
UNCAUGHT_EXCEPTION = 'uncaughtException',

// code to catch uncaught exceptions and re-emit
// them for consumers to process the error.
// if no listener is attached for uncaughtException
// event, it will continue with the default behavior.
UNCAUGHT_EXCEPTION_HANDLER = `
;(function (bridge) {
const onError = function (event, throwError) {
if (bridge.listeners('${UNCAUGHT_EXCEPTION}').length) {
event.preventDefault();
return bridge.emit('${UNCAUGHT_EXCEPTION}', event.error || event.reason);
}
if (throwError) {
event.preventDefault();
throw event.error || event.reason;
}
};
self.addEventListener('error', onError);
self.addEventListener('unhandledrejection', (e) => onError(e, true));
})(bridge);
`,

/**
* Returns the firmware code to be executed inside Web Worker.
*
* @private
* @param {String} code -
* @param {String} id -
* @param {Boolean} debug -
* @return {String}
*/
sandboxFirmware = (code, id) => {
sandboxFirmware = (code, id, debug) => {
// @note self.postMessage and self.addEventListener methods are cached
// in variable or closure because bootCode might mutate the global scope
return `
!${debug} && (console = new Proxy({}, { get: function () { return function () {}; } }));
__uvm_emit = function (postMessage, args) {
postMessage({__id_uvm: "${id}",__emit_uvm: args});
}.bind(null, self.postMessage);
__uvm_addEventListener = self.addEventListener;
try {${code}} catch (e) { setTimeout(function () { throw e; }, 0); }
${code}
(function (emit, id) {
__uvm_addEventListener("message", function (e) {
(e && e.data && (typeof e.data.__emit_uvm === 'string') && (e.data.__id_uvm === id)) &&
Expand All @@ -55,6 +77,12 @@ module.exports = function (bridge, options, callback) {

const id = UVM_ID_ + randomNumber(),

// We append the uncaught exception handler before the
// provided bootCode to ensure that the handler has
// access to `bridge` and other global values before
// the boot code mutates them (if at all).
safeBootCode = UNCAUGHT_EXCEPTION_HANDLER + options.bootCode,

// function to forward messages emitted
forwardEmits = (e) => {
if (!(e && e.data && (typeof e.data.__emit_uvm === 'string') && (e.data.__id_uvm === id))) { return; }
Expand All @@ -72,7 +100,7 @@ module.exports = function (bridge, options, callback) {
},

// function to terminate worker
terminateWorker = function () {
terminateWorker = function (callback) {
if (!worker) { return; }

// remove event listeners for this sandbox
Expand All @@ -89,6 +117,7 @@ module.exports = function (bridge, options, callback) {
}

worker = null;
callback && callback();
};

// on load attach the dispatcher
Expand All @@ -112,12 +141,13 @@ module.exports = function (bridge, options, callback) {
});

// get firmware code string with boot code
firmwareCode = sandboxFirmware(bridgeClientCode(options.bootCode), id);
firmwareCode = sandboxFirmware(bridgeClientCode(safeBootCode), id, options.debug);

// start boot timer, stops once we get the load signal, terminate otherwise
bootTimer = setTimeout(() => {
terminateWorker();
callback(new Error(`uvm: boot timed out after ${options.bootTimeout}ms.`));
terminateWorker(() => {
callback(new Error(`uvm: boot timed out after ${options.bootTimeout}ms.`));
});
}, options.bootTimeout);

// if sandbox worker is provided, we simply need to init with firmware code
Expand Down Expand Up @@ -145,6 +175,7 @@ module.exports = function (bridge, options, callback) {
// don't set `onmessage` and `onerror` as it might override external sandbox
worker.addEventListener(MESSAGE, forwardEmits);
worker.addEventListener(ERROR, forwardErrors);
worker.addEventListener(MESSAGE_ERROR, forwardErrors);

// equip bridge to disconnect (i.e. terminate the worker)
bridge._disconnect = terminateWorker;
Expand Down

0 comments on commit 9c9d5d4

Please sign in to comment.