0

This seems like a problem that requires some JS expertize that I'm apparently not in posses. I'm writing a scripting module for an app. The scripting and the app are in Javascript.

It will be used by developers to provide scripting extensions to various modules that run on demand or when triggered by something. I've got a request to alter the way it works in order to simplify the coding of the scripts. And I'm kinda stuck since I don't know how to proxy local variables inside the script into an external object.

This is a sample of what works currently:

// this is part of the app developer API
function compileScript(userScript, argSignature) {

    let _scriptFunc_ = null;

    /* scripting API - there are available in scripts */
    const print = function(message, window) {
        const msg = document.createElement("span")
        msg.innerText = message;
        document.getElementById("output").append(msg)
        document.getElementById("output").append(document.createElement("br"))
    };
    /* end Scripting API section */

    try {
        const scriptSource = `
                (async () => {
                    try {
                        ${userScript}
                    } catch (err) {
                        //_errEmit.fire(err)
                    }
                })()
        `; // wrap the execution in async so they can use await in their userScript

        // argument signatures are defined by the module that "compiles" the script
        // they call it with those and use them inside the userScript
        eval("_scriptFunc_ = function(" + argSignature + ") {\n" + scriptSource + "\n}");
    }
    catch (err) {
        //EvtScriptEmitEvalError.fire(err); // script "compilation" exception
        return null;
    }

    return _scriptFunc_.bind(this);
}

// USAGE

// have some context with "variables" inside accessible by the script
// changes to this might be propagated elsewhere if it has property getters/setter
let scriptData = {
    something: 10
};

// define the script
let userScript = `
 for (let i = 0; i < count; i++) {
    this.something++; // this changes scriptData
    print(this.something)
  }
`
// "compile" and run
const script = compileScript.call(scriptData, userScript, "count")
script(5) // output: 11,12,13,14,15
console.log(scriptData.something) // 15
<pre id="output">

</pre>
Note the usage of "this." inside the script to refer to scriptData members.

The request they have is to access the properties of the scriptData object inside the script by simply referring to its properties as if they were variables inside the script.

This is how they would want to write it (note there is no "this." before something):

let userScript = `
 for (let i = 0; i < count; i++) {
    something++; // this changes scriptData
    print(something)
  }
`

They are fine with possible name collisions between parameters and members of scriptData, it is developer work to set that up correctly.

My problem, tough, is that I don't have any idea how to modify "compileScript" in order to inject members of scriptData as plain variables inside the script is such a way that they proxy to the scriptData object. It is easy to define a function in the scope of compileScript like "print", but I have no ideas on how to do this concept of "proxy variables".

"with" is not available in strict mode which the app runs in. JS Proxy class does not seem useful. Deconstructing scriptData into variables can be done but those variables are no longer going to the scriptData object, they are local. Defining property getters/setters is available only for objects, not for the compileScript function...

I cannot modify the scriptData object, the user passes it as is. I can only tweak the generation of the script so that it behaves as required.

It should also work in a web worker (so no global scope / window), since a script could be triggered at the completion of a web worker.

Any ideas?

Marino Šimić
  • 7,318
  • 1
  • 31
  • 61

2 Answers2

1

You're looking for the with statement. It's the only way to make variable assignments become interceptable as property assignments on your scriptData object (apart from going the full way to transpiling the script code with something like babel).

If writable variables are not necessary, you could also use this technique to inject values into the scope of a function. I would recommend to use it anyway, instead of eval.

/* scripting API - there are available in scripts */
const api = {
    print(message, window) {
        const msg = document.createElement("p");
        msg.textContent = message;
        document.getElementById("output").append(msg);
    },
};
/* end Scripting API section */

// this is part of the app developer API
function compileScript(context, userScript, parameters) {
    const scriptSource = `
        const { ${Object.keys(api).join(', ')} } = api;
        with (this) {
            return async (${parameters.join(', ')}) => {
                "use strict";
                try {
                    ${userScript}
                } catch (err) {
                    //_errEmit.fire(err)
                }
            };
        }
    `;
    try {
        return new Function('api', scriptSource).call(context, api);
    } catch (err) {
        console.warn(err); // script "compilation" exception
        return null;
    }
}

let scriptData = {
    something: 10
};

// define the script
let userScript = `
  for (let i = 0; i < count; i++) {
    something++; // this changes scriptData
    print(something)
  }
`;
// "compile" and run
const script = compileScript(scriptData, userScript, ["count"])
script(5) // output: 11,12,13,14,15
console.log(scriptData.something) // 15
<pre id="output">

</pre>
Bergi
  • 630,263
  • 148
  • 957
  • 1,375
  • with is not available in strict mode which the app is running in. I'll amend my question to add that as well, I forgot. – Marino Šimić Oct 23 '22 at 17:17
  • Doesn't matter, you don't need to run the compiled user scripts in strict mode. – Bergi Oct 23 '22 at 17:19
  • Nice way to separate the Api. However I am getting the "with is not allowed in strict mode". Our build makes the transpiled sources strict. :( – Marino Šimić Oct 23 '22 at 18:28
  • no sorry i left it on another place by mistake, when inside the call through the function class it works! – Marino Šimić Oct 23 '22 at 18:40
  • However now i have a change in behavior. In a script you can write "whatever = 1" and it will compile and run. Before I would have had "ReferenceError: whatever is not defined" – Marino Šimić Oct 23 '22 at 19:03
  • no, sorry again the error was not reported because of my copy pasting directly without fixing from SO :) – Marino Šimić Oct 23 '22 at 19:14
0

This might not be a general solution but a working solution for your problem:

I think it would be easier to auto-generate the this.-prefix before every corresponding variable name by intercepting the scriptData object's own property names and prepending a this..

It could look similar to this untested code:

const variableNamePattern = new RegExp(String.raw`(?<![\."])(\s*)\b(${ Object.keys(scriptData).join("|") })\b`, "g")
userScript.replaceAll(variableNamePattern, "$1(this.$2)")

The solution has several problems such as replacing words in comments (negligable) and string literals (potentially undesirable) or replacing parameter names if the scriptData property names and argument names coincide (ambiguity).

A better solution which bypasses the string literal problem could be to avoid the replacement of text inside string literals and comments of the script.

let startIndex = 0
let userScriptList = []
const stringLiteralPattern = String.raw`(?<!\\)(["${"`"}'])[\s\S]*?(?<!\\)\1`
const commentPattern = String.raw`/\*[\s\S]*?\*/|//.*`
for ( const match of userScript.matchAll(new RegExp(stringLiteralPattern + "|" + commentPattern, "dg")) ) {  // the "d" flag provides us a start and end index
    userScriptList.push(userScript.substring(startIndex,startIndex=match.indices[0][0])) // match.indices[0][0] = 0-based start index of the (i+1)-th string in the userScript
    userScriptList.push(userScript.substring(startIndex,startIndex=match.indices[0][1])) // match.indices[0][1] = 0-based end index of the i-th string in the userScript
}
userScriptList.push(userScript.slice(startIndex))

const newUserScript = userScriptList.map((piece, i) => (i % 2 === 0) ? piece.replaceAll(variableNamePattern, "$1(this.$2)") : piece ).join("")

However, the ambiguity problem is not specific to my solution but related to your general question or the feature you were requested to implement. Other languages treat the parameter so that it shadows the property. Then the property is required to be accessed explicitly with this for disambiguation. The variableNamePattern above does only match variables that do not follow a dot. In order to enable disambiguation, it requires the removal of parameter names from the Object.keys().

As a note to the other answer, using with is discouraged, even officially. Who knows, whether they ever decide to drop the with-keyword? And as we see in our example, if you have both an argument and a property with an equal name, you don't know the origin anymore.

There are quite some design flaws in JavaScript but one thing I like is the forced usage of this in front of member names. It adds clarity to your code and does not feel weird if you are required to use it. Maybe, it's not the best idea to remove the need of writing this for JavaScript code.

ChrisoLosoph
  • 459
  • 4
  • 8
  • "*Who knows, whether they ever decide to drop the with-keyword?*" - they will never break the web, you're safe there. Yes, it's discouraged, and I agree that it might be a bad idea, but it's the only way to achieve this dynamic scoping without code rewriting (which is hard to get right, as your answer explains). "*I like is the forced usage of this in front of member names.*" - I'm fond of it for object member names in methods as well, but the user scripts in the OP's case have more of a "unscoped script" feeling. Maybe OP should place the burden to add `with(this)` on the script authors… – Bergi Aug 22 '23 at 23:12