mde / ejs

Embedded JavaScript templates -- http://ejs.co
Apache License 2.0
7.7k stars 841 forks source link

Add custom function constructor option #711

Open ncake opened 1 year ago

ncake commented 1 year ago

This adds an option called functionClass, which purpose is to allow to bring your own execution runtime.

Here's a basic example of how one could integrate quickjs-emscripten module to execute code in a WASM-powered virtual machine.

// index.js
import LoadSafeEJS from "./safe-ejs.js";

const safeEJS = await LoadSafeEJS();
const res = safeEJS.render("<%- 'hello ' + world %>", {world: 'world'});
console.log(res);
safeEJS.dispose();
// safe-ejs.js
import ejs from 'ejs/ejs.js';
import { getQuickJS } from "quickjs-emscripten"

/** @param {import('quickjs-emscripten/dist').QuickJSWASMModule} QuickJS */
function SafeEJS(QuickJS){
    const vm = QuickJS.newContext();

    this.dispose = function(){
        vm.dispose();
    };

    this.render = function(source, data, ejsOptions){
        return ejs.render(source, data, {...ejsOptions, functionClass})
    };

    const functionClass = function(argNames, funcBody){
        // not implemented here:
        // - caching the function between calls (thus useless for ejs.compile)
        // - async, include(), probably more
        return (locals, escapeFn, include, rethrow) => {
            // create function from source
            const ctor = vm.getProp(vm.global, 'Function');
            const argNamesHandle = vm.newString(argNames);
            const funcBodyHandle = vm.newString(funcBody);
            const newFuncRet = vm.callFunction(ctor, vm.undefined, argNamesHandle, funcBodyHandle);
            ctor.dispose();
            argNamesHandle.dispose();
            funcBodyHandle.dispose();
            const newFunc = vm.unwrapResult(newFuncRet);
            // wrap user-passed data into quickjs values
            const handleList = [];
            const makeDisposable = handle => (handleList.unshift(handle), handle);
            const localsHandle = wrapValue(locals, makeDisposable);
            // wrap the rest of the arguments
            const escapeFnHandle = vm.newFunction('escapeFn', strHandle => {
                const str = vm.getString(strHandle);
                const res = escapeFn(str);
                return vm.newString(res);
            });
            const includeHandle = vm.undefined;
            const rethrowHandle = vm.newFunction('rethrow',
                (errHandle, strHandle, flnmHandle, linenoHandle) => {
                    const str = vm.getString(strHandle);
                    const flnm = vm.getString(flnmHandle);
                    const lineno = vm.getNumber(linenoHandle);
                    const errMsg = vm.getProp(errHandle, 'message');
                    const errName = vm.getProp(errHandle, 'name');
                    const errStack = vm.getProp(errHandle, 'stack');
                    const err = new Error();
                    if(errMsg !== vm.undefined) err.message = vm.getString(errMsg);
                    if(errName !== vm.undefined) err.name = vm.getString(errName);
                    if(errStack !== vm.undefined) err.stack = vm.getString(errStack);
                    rethrow(err, str, flnm, lineno, escapeFn);
                }
            );
            // execute our function
            const ret = vm.callFunction(
                newFunc, vm.undefined, localsHandle,
                escapeFnHandle, includeHandle, rethrowHandle
            );
            // dispose of everything to prevent memory leaks
            escapeFnHandle.dispose();
            rethrowHandle.dispose();
            for(const handle of handleList){
                handle.dispose();
            }
            newFunc.dispose();
            // return or throw an error
            const res = vm.unwrapResult(ret);
            const str = vm.getString(res);
            res.dispose();
            return str;
        };
    };

    const wrapValue = function(value, makeDisposable){
        if(value === undefined) return vm.undefined;
        if(value === null) return vm.null;
        if(typeof value === "boolean") return value ? vm.true : vm.false;
        if(typeof value === "number") return makeDisposable( vm.newNumber(value) );
        if(Array.isArray(value)){
            const arr = makeDisposable( vm.newArray() );
            for(let i = (value.length - 1); i >= 0; i--)
                vm.setProp(arr, i, wrapValue(value[i], makeDisposable));
            return arr;
        }
        if(typeof value === "object"){
            const obj = makeDisposable( vm.newObject() );
            for(const key in value)
                vm.setProp(obj, key, wrapValue(value[key], makeDisposable));
            return obj;
        }
        if(value.toString) return makeDisposable( vm.newString(value.toString()) );
        return vm.undefined;
    };
}

async function LoadSafeEJS(){
    return new SafeEJS(await getQuickJS());
}

export default LoadSafeEJS;
ralyodio commented 1 year ago

can we get this merged?

teoboley commented 2 weeks ago

Can this get merged? I would love to use quickjs-sandboxed templates in my app

teoboley commented 1 week ago

@mde pls