Warning: A lot of things discussed in this post are "hackish" and not best practice. However, there are some one-off scenarios where they are helpful, and exploring their use gives us some insight into how NodeJS works.
Example Scenario: Executing a file with side-effects from another
Here is the setup. We have file as-is.js
, which for some reason, we cannot edit to add imports. Maybe it is third-party vendor code, maybe we need to use it later in an environment that does not allow imports, or maybe we need to execute it with special imported values only for a one off test.
Example "as-is.js
":
var mode = '';
if (window.location.host === 'localhost'){
mode = 'debug mode';
}
else {
mode = 'production mode';
}
console.log(mode);
Now, let’s say that we want to do multiple things:
- Inject a fake value for "
window.location.host
" - Override "
console.log
" with our own method that redirects the output to a file - Capture the value of "
mode
"
We will do all things in a file called "with.js
":
const fs = require('fs');
// Inject window global
var window = {
location: {
host: 'localhost'
}
};
// Mutate console.log method
console.log = function(input){
input = input + '\n';
fs.writeFileSync(__dirname + '/output.txt',input,{
flag: 'a'
})
};
// Run as-is.js
require(__dirname + '/as-is.js');
// Try to read captured `mode` variable
console.log(`Mode captured from "as-is.js" is ${mode}`);
Right now, that file doesn’t achieve our goals and doesn’t work.
Here are some options for implementation
Polluting the global variable scope
In NodeJS, one way to share variables between file is to make them global variables.
One way to do this is to completely remove the variable declaration keyword. If I change "var foo = 'bar';
" to "foo = 'bar';
", I have now made "foo
" a global variable.
You can also use "global.foo = 'bar'
" as another way to declare a global in node.
Here is our modified "with.js
":
const fs = require('fs');
// Inject window global
window = {
location: {
host: 'localhost'
}
};
// Mutate console.log method
console.log = function(input){
input = input + '\n';
fs.writeFileSync(__dirname + '/output.txt',input,{
flag: 'a'
})
};
// Run as-is.js
require(__dirname + '/as-is.js');
// Try to read captured `mode` variable
console.log(`Mode captured from "as-is.js" is ${mode}`);
The only change that has been made is "var window = ...
" to "window = ...
", making it a global variable. Note that this change does not need to be made to "console
", since it is already a global.
However, there is still an issue with this code. The "mode
" variable is not a global in "as-is.js
", so it can’t be read by "with.js
"! The last line will of "with.js
" fails!
Eval the file contents
Since we can’t touch "as-is.js
" to change "var mode = ...
" to "mode = ...
" and make it a global, we need some other way to capture its value. A quick hack way to do this is to use "eval()
". By default, in non-strict-mode, eval runs code in the same lexical scope as where it is called, so variables can be shared across.
Modified "with.js
":
const fs = require('fs');
// Inject window global
var window = {
location: {
host: 'localhost'
}
};
// Mutate console.log method
console.log = function(input){
input = input + '\n';
fs.writeFileSync(__dirname + '/output.txt',input,{
flag: 'a'
})
};
// Run as-is.js
const rawAsIsCode = fs.readFileSync(__dirname + '/as-is.js').toString();
eval(rawAsIsCode);
// Try to read captured `mode` variable
console.log(`Mode captured from "as-is.js" is ${mode}`);
Notice that we were able to change "window = ...
" back to "var window = ...
", and it was still able to share its value with "as-is.js
" due to the scope sharing of "eval()
".
Note: The value of "mode
" still can’t be read, if the eval’ed code contains the "use strict
" directive; in that case, "as-is.js
" would get its own lexical environment, which is to say, its own scope.
Use NodeJS VMs
Something that is unique to Node is the vm
module API.
With this exposed API, we can create mini sandboxes with specific context, to run code in. This is pretty similar to "eval()
", but offers a little more isolation and control.
Modified "with.js
":
const fs = require('fs');
const vm = require('vm');
// Inject window global
var window = {
location: {
host: 'localhost'
}
};
// Mutate console.log method
console.log = function(input){
input = input + '\n';
fs.writeFileSync(__dirname + '/output.txt',input,{
flag: 'a'
});
};
// Run as-is.js
const rawAsIsCode = fs.readFileSync(__dirname + '/as-is.js').toString();
// Create scoped context with shared vars
var mode;
const vmContext = vm.createContext({
console,
window,
mode
});
// Run code in context
vm.runInContext(rawAsIsCode,vmContext);
// Try to read captured `mode` variable
console.log(`Mode captured from "as-is.js" is ${vmContext.mode}`);
There are a few things important things to note about this approach:
- The explicit context setup of the vm allows us to capture the value of "
mode
", even if "as-is.js
" is in "strict mode
" - Like "
eval()
", all the vm run methods expect code as a string. - Variables have to be explicitly shared via "
context
"
File concatenation
This is kind of a silly solution, but it works! We will basically pretend that the two files are one, and merge the code together with string concatenation.
CLI Solution
The only special part of this solution is that we need to move our final reading of the "mode
" value to the end, by placing it in a separate file and concatenating it last. So the values of the files as they are now are:
as-is.js
:
'use strict';
var mode = '';
if (window.location.host === 'localhost'){
mode = 'debug mode';
}
else {
mode = 'production mode';
}
console.log('Mode set');
with.js
:
const fs = require('fs');
const vm = require('vm');
// Inject window global
var window = {
location: {
host: 'localhost'
}
};
// Mutate console.log method
console.log = function(input){
input = input + '\n';
fs.writeFileSync(__dirname + '/output.txt',input,{
flag: 'a'
})
};
with-final.js
:
// Try to read captured `mode` variable
console.log(`Mode captured from "as-is.js" is ${mode}`);
And finally, we can concatenate the code from these files and run them from the command line with a single line:
node -e "eval(fs.readFileSync('./with.js').toString() + fs.readFileSync('./as-is.js').toString() + fs.readFileSync('./with-final.js').toString());"
Wrap-up
These are just some fun examples of messing with NodeJS scope and context; they are far from the only options. Furthermore, depending on what you are looking to do (testing, module mocking, etc), there is likely a library or existing solution to help you out.
For example, if you are looking to mock the global window
object, since that is a browser API and does not exist in NodeJS, please take a look at jsdom. Or for mocking modules, mock-require.
Finally, if you have the power to actually edit the files you need to share values with, then obviously you should use actual established methods for doing so, such as the module system of exports and imports.