From 3ed9cbb0568e217549c9b97168be3e67818c6ad4 Mon Sep 17 00:00:00 2001 From: Michael Froelich Date: Sat, 10 Jun 2017 22:05:00 +1000 Subject: [PATCH] FEngine, provides RenderHtml and TransformCode FEngine is my replacement for React.NET and responds in about a third the time, the first call of RenderHtml is dreadfully slow and the second isn't much better but I think this is due to some optimisation causing a static object to be instanstiated late as well as some internal VroomJs caching not kicking in early enough. Otherwise fairly unchanging props or consistent and expected props will give faster response times than React.NET. The other added bonus is that now this project is truly cross platform as the only library React.NET was accommodating for which was cross platform was VroomJs, as each other was specifically windows. For whatever reason, React.NET wouldn't always load the native VroomJs binary, but when using VroomJs directly the binary could be loaded in the same directory as the debug or release folder. --- FEngine.cs | 524 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 FEngine.cs diff --git a/FEngine.cs b/FEngine.cs new file mode 100644 index 0000000..7006614 --- /dev/null +++ b/FEngine.cs @@ -0,0 +1,524 @@ +/* + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + Author: Michael J. Froelich + */ +using Newtonsoft.Json; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using VroomJs; +namespace FAP +{ + /// + /// Engine class for rendering JSX files to HTML and for transforming JSX files to a more standardised script + /// Please try not to instanstiate this class too much. Debug code within here for those working closely with the Js Engine + /// + public class FEngine + { + /// + /// Will either load a script from this path if a path is stored + /// or it will search around the binary for a script with this in its filename + /// Defaults are in this order: Babel, requirejs (but will not flag an error if missing) + /// + public List BabelScriptPaths { get; set; } = new List(new string[] { "babel" }); + + /// + /// Will either load a script from this path if a path is stored or it will search around the binary for a script with this in its filename + /// These scripts will not update on runtime but will update all instances when accessed + /// Defaults are in this order: React, ReactDOMServer, requirejs (but will not flag an error if missing) + /// + public List ReactScriptPaths { get; set; } = new List(new string[] { "react", "react-dom-server" }); + /// + /// JsPool of instances used to transform script, consume by using(var engine = BabelPool.GetContext()) { + /// Contains Babel + /// Common functions are engine.Execute and engine.Set/engine.Get variables + /// + public static JsPool BabelPool; + /// + /// JsPool of instances used to render HTML, consume by using(var engine = ReactPool.GetContext()) { + /// Contains React, React-DOM-Server and anything included as a "mock" script + /// Common functions are engine.Execute and engine.Set/engine.Get variables + /// + public static JsPool ReactPool; + +#if DEBUG + public List BlankScriptPaths { get; set; } = new List(); + public static JsPool BlankPool; + + private string blankscript; + private string blankScriptGet() => blankscript ?? String.Empty; +#endif + private string babelscript; + private string babelScriptGet() => babelscript ?? String.Empty; //Used to dynamically get the complete scripts + + private string reactscript; + private string reactScriptGet() => reactscript ?? String.Empty; + + /// + /// Whatever you give me, know that the purpose of this entire class is to run babel.js + /// A word of caution to the lazy, someone who expects you to forget will begin to expect + /// Consequences. + /// + /// Scripts run between loading Babel and performing a Transform function from Babel + /// Scripts run before performing a Render function from React, finished component scripts etc + /// Unused to keep memory down, but would have enabled a free sandbox for scripts + public FEngine(List babelScriptPaths = null, List reactScriptPaths = null, List blankScriptPaths = null) + { + string requireisnice = null; + RenderCache = new Dictionary(); + if (!AssemblyLoader._isLoaded && IsWindows) { + AssemblyLoader.EnsureLoaded(); //The three lines of code too difficult for the officialised system + } + if (reactScriptPaths != null) { + ReactScriptPaths = reactScriptPaths; + if (ReactivePage.JsFolder == Directory.GetCurrentDirectory().ToString() && File.Exists(reactScriptPaths.FirstOrDefault())) { + ReactivePage.JsFolder = Path.GetDirectoryName(reactScriptPaths[0]); //Get this away from what's usually the executing binary as soon as possible + } + } + else { + for (int i = 0; i < ReactScriptPaths.Count; i++) { + string p; + if ((p = ReactivePage.Search(ReactScriptPaths[i])) == null) { + throw new Exception("Cannot find essential script " + ReactScriptPaths[i] + "\nEither set the JS folder from ReactivePage.JsFolder or run the ReactivePage.DownloadScripts function."); + } + else + ReactScriptPaths[i] = p; + } + } + if ((requireisnice = ReactivePage.Search("require")) != null && !ReactScriptPaths.Contains("require")) + ReactScriptPaths.Add(requireisnice); //by default, it's nice to allow require and import like lines, if you're into that and have it around + + if (babelScriptPaths != null) + BabelScriptPaths = babelScriptPaths; + else { + for (int i = 0; i < BabelScriptPaths.Count; i++) { + if ((BabelScriptPaths[i] = ReactivePage.Search(BabelScriptPaths[i])) == null) { + Console.Error.WriteLine("Cannot find babel.js this means any script requiring transformation included will throw errors"); + } + } + } + if (!string.IsNullOrEmpty(requireisnice) && !ReactScriptPaths.Contains("require")) { + BabelScriptPaths.Add(requireisnice); + } +#if DEBUG + if (blankScriptPaths != null) + BlankScriptPaths = blankScriptPaths; + else + for (int i = 0; i < BlankScriptPaths.Count; i++) + BlankScriptPaths[i] = ReactivePage.Search(BlankScriptPaths[i]); + IncludeScripts(BlankScriptPaths, false, Machine.Blank); + if (BlankPool == null) + BlankPool = new JsPool(babelScriptGet); +#endif + IncludeScripts(ReactScriptPaths, false, Machine.React); + IncludeScripts(BabelScriptPaths, false, Machine.Babel); + } + + /// + /// Enum used for specifying which machine to add scripts to, adding scripts to the React machine + /// enables validation for scripts passed into that machine whereas adding scripts to the Babel + /// machine enables validation for scripts being transformed. It all depends where you're getting + /// invalidation errors, at program startup or upon connecting with a browser. + /// + public enum Machine + { + /// + /// The react render engine, these scripts are run when a user connects + /// + React, + /// + /// The babel transformation engine, these scripts are run when loaded so only plain JS is used + /// + Babel, + //Blank + } + + /// + /// Includes or executes these scripts on the startup of either Js Context: the render method or babel transform + /// + /// Pathname/Path to the script to include + /// Whether or not to transform the code before passing it into the context + /// Either FAP.Machine.Render or FAP.Machine.Babel or FAP.Machine.Blank + /// + public bool IncludeScript(string Pathname, bool UseRenderMachine = false, Machine machine = Machine.React) + { + return IncludeScripts(new[] { Pathname }, UseRenderMachine, machine); + } + /// + /// Pathname/Path to the script to include + /// Whether or not to transform the code before passing it into the context + /// Either FAP.Machine.Render or FAP.Machine.Babel or FAP.Machine.Blank + /// + public bool IncludeScripts(IEnumerable Pathname, bool UseRenderMachine = false, Machine machine = Machine.React) + { + List scriptsToWork = null; + List paths = new List(); + var inputcopy = Pathname.ToArray(); //IEnumerables aren't nice to work with + foreach (string s in inputcopy) + paths.Add(ReactivePage.Search(s)); //ensures paths + bool HasList = true; + switch (machine) { + case Machine.React: + scriptsToWork = ReactScriptPaths; + break; + case Machine.Babel: + scriptsToWork = BabelScriptPaths; + break; +#if DEBUG + case Machine.Blank: + scriptsToWork = BlankScriptPaths; + break; +#endif + default: + throw new Exception("Not supported"); + } + string scripttowork = string.Empty; + foreach (string s in inputcopy) //removes any unensured paths + if (scriptsToWork.Contains(s)) + scriptsToWork.Remove(s); + scriptsToWork.AddRange(paths); + + paths.ForEach(s => HasList &= File.Exists(s)); //One last very quick check.. + if (HasList) { + scripttowork = concatScripts(paths, UseRenderMachine); + switch (machine) { + case Machine.React: + reactscript += scripttowork; + ReactPool = new JsPool(reactScriptGet); + break; +#if DEBUG + case Machine.Blank: + blankscript += scripttowork; + BlankPool = new JsPool(blankScriptGet); + break; +#endif + case Machine.Babel: + babelscript += scripttowork; + BabelPool = new JsPool(babelScriptGet); + break; + } + } + else + throw new Exception("22: Engine include script error, non existent paths in the script path list of " + machine.ToString()); + return true; + } + + private string concatScripts(List scripts, bool please) + { + StringBuilder sb = new StringBuilder(); + foreach (string scripttocompile in scripts) { + if (scripttocompile.EndsWith(".jsx") || please) + sb.Append(TransformCode(File.ReadAllText(scripttocompile))).Append("\n;\n"); + else + sb.Append(File.ReadAllText(scripttocompile)).Append("\n;\n"); + } + return sb.ToString(); + } + /// + /// ID used on the first div element and passed by to the ReactDOM function, default is rootComponent + /// I have only a hazy idea why you'd want to change it + /// + public string RootComponentId { get; set; } = "rootComponent"; + + private string GetReactRenderScript(string ComponentName, string props) => + string.Format("ReactDOMServer.renderToString(React.createElement({0},{1}));", ComponentName, props); + private string GetHtmlRenderScript(string ComponentName, string props) => + string.Format("ReactDOMServer.renderToStaticMarkup(React.createElement({0},{1}));", ComponentName, props); + /// + /// Provides one line of script as a string used for attaching the ReactJS engine client side to the RootComponentId + /// + /// The java script + /// Component name + /// Properties serialised into a string + public string RenderJavaScript(string ComponentName, object props) => + RenderJavaScript(ComponentName, JsonConvert.SerializeObject(props)); + /// + /// Provides one line of script as a string used for attaching the ReactJS engine client side to the RootComponentId + /// + /// The java script + /// Component name + /// Properties serialised into a string + public string RenderJavaScript(string ComponentName, string props) => + string.Format("ReactDOM.render(React.createElement({0},{1}), document.getElementById('{2}'));", ComponentName, props, RootComponentId); + + /// + /// Renders the html. + /// + /// The html + /// Component name + /// Properties serialised into a string + /// Input scripts + public string RenderHtml(string ComponentName, string props, IEnumerable InputScripts = null) => RenderHtml(ComponentName, props, false, InputScripts); + /// + /// Renders the html. + /// + /// The html + /// Component name + /// Properties as a serialisable object + /// Input scripts + public string RenderHtml(string ComponentName, object props, IEnumerable InputScripts = null) => RenderHtml(ComponentName, props, false, InputScripts); + + /// If true, react information shall not be included + /// + public string RenderHtml(string ComponentName, object props, bool HtmlOnly = false, IEnumerable InputScripts = null) + { + string sprops = JsonConvert.SerializeObject(props); + return RenderHtml(ComponentName, sprops, HtmlOnly, InputScripts); + } + /// + /// Renders the html. + /// + /// The html + /// Component name + /// Properties + /// If set to true html only + /// Input scripts + public string RenderHtml(string ComponentName, string props, bool HtmlOnly = false, IEnumerable InputScripts = null) + { + ReactivePage.Component dangerousidea; + if (ReactivePage.defaults.TryGetValue(ComponentName.ToLower(), out dangerousidea)) { + return RenderHtml(ComponentName, props, HtmlOnly, dangerousidea, InputScripts); + } + return RenderHtml(ComponentName, props, HtmlOnly, null, InputScripts); + } + internal string RenderHtml(string ComponentName, string props, bool HtmlOnly = false, ReactivePage.Component components = null, IEnumerable InputScripts = null) + { + StringBuilder renderBuild = new StringBuilder(); + string scriptnameforvroom = "Anonymous"; + if (components != null && components.ComponentScriptPathinfo.Count > 0) { + foreach (ReactivePage.Script s in components.ComponentScriptPathinfo.Values) { + if (string.IsNullOrEmpty(s.RenderedComponentScript)) + renderBuild.AppendLine(s.ComponentScript); + else + renderBuild.AppendLine(s.RenderedComponentScript); + + } + scriptnameforvroom = Path.GetFileName(components.ComponentScriptPathinfo.Last().Value.ScriptPath); //Assumedly, the last script would contain the renderable component + } + if (InputScripts != null) { + foreach (string s in InputScripts) + renderBuild.AppendLine(s); + } + if (HtmlOnly) + renderBuild.Append(GetHtmlRenderScript(ComponentName, props)); + else + renderBuild.Append(GetReactRenderScript(ComponentName, props)); + string toRender = renderBuild.ToString(); + string toReturn; + int hash = toRender.GetHashCode() + props.GetHashCode(); //It's still faster to hash both these and check if something with this script and props has come than to run javascript + if (!RenderCache.TryGetValue(hash, out toReturn)) { + using (var pool = ReactPool.GetContext()) { + var instance = pool.Instance; + var output = instance.Execute(toRender, scriptnameforvroom); + string html = output as string; + toReturn = string.Format("
{1}
", RootComponentId, html); + } + RenderCache.Add(hash, toReturn); + } + return toReturn; + } + Dictionary RenderCache; //haxxy cache to prevent someone spamming from creating contexes + + /// + /// Minimises the output gained from performing the Transform functions. Default is false. + /// + public bool MinimiseBabelOutput { get; set; } = true; + public List BabelPresets { get; set; } = new List { + "stage-2",//Saves 10ms against other stages when benchmarking debug, as of writing + "es2015", + "react" + }; + + /// + /// Currently unsure how these are used, assumedly including these here and as "mock scripts" from reactive page enables middleware? + /// + public List BabelPlugins { get; set; } = new List(); + + /// + /// Parser options sent to Babylon, what's really transforming code. Set this by making it equal to an anonymous object, such as: + /// ParserOptions = new { allowImportExportEverywhere = true, allowReturnOutsideFunction = true }; + /// Which are the defaults, since you'd rather more code than less transforming. Alert me if plugins or presets cease working, + /// it would be this line of code here. Set to null for the Babel/Babylon's true default. + /// + public object ParserOptions { get; set; } = new { allowImportExportEverywhere = true, allowReturnOutsideFunction = true }; + + private const string RenderOutputVariable = "_FAP_Render_Output"; + private const string RenderInputVariable = "_FAP_Render_Input"; + private const string ParserOptionsConst = ", parserOpts: "; + private readonly string TransformCodeScriptDebug = RenderOutputVariable + " = Babel.transform(" + RenderInputVariable + ", { retainLines: true, presets: "; + private readonly string TransformCodeFileDebug = RenderOutputVariable + " = Babel.transformFile(" + RenderInputVariable + ", { retainLines: true, presets: "; + private readonly string TransformCodeScript = RenderOutputVariable + " = Babel.transform(" + RenderInputVariable + ", {minified: true, comments: false, presets: "; + private readonly string TransformCodeFile = RenderOutputVariable + " = Babel.transformFile(" + RenderInputVariable + ", {minified: true, comments: false, presets: "; + private readonly string TransformCodeTrailer = "}).code;"; + /// + /// Calls the internal Babel transform function with Pathname passed in as the first parameter + /// + /// + /// Null if failure + public string TransformFile(string Pathname, string ScriptName = "Anonymous") + { + string toret = null; + try { + if (ScriptName == "Anonymous") + ScriptName = Path.GetFileName(Pathname); + using (var instance = BabelPool.GetContext()) { + string plugins = string.Empty; + if (BabelPlugins.Count > 0) + plugins = ", plugins: " + JsonConvert.SerializeObject(BabelPlugins); + instance.Instance.SetVariable(RenderInputVariable, Pathname); + if (!MinimiseBabelOutput) + instance.Instance.Execute( + TransformCodeFileDebug + JsonConvert.SerializeObject(BabelPresets) + plugins + + (ParserOptions != null ? ParserOptionsConst + JsonConvert.SerializeObject(ParserOptions) : string.Empty) + + TransformCodeTrailer, ScriptName); + else + instance.Instance.Execute( + TransformCodeFile + JsonConvert.SerializeObject(BabelPresets) + plugins + + (ParserOptions != null ? ParserOptionsConst + JsonConvert.SerializeObject(ParserOptions) : string.Empty) + + TransformCodeTrailer, ScriptName); + toret = instance.Instance.GetVariable(RenderOutputVariable) as string; + } + } + catch (Exception e) { + Console.Error.WriteLine("20: Babel Transformation Error\n" + e.Message); + } + return toret; + } + public string TransformCode(string code, string ScriptName = "Anonymous") + {//scriptnameforvroom = Path.GetFileName(components.ComponentScriptPathinfo.FirstOrDefault().Value.ScriptPath); + string toret = null; + try { + using (var instance = BabelPool.GetContext()) { + string plugins = string.Empty; + if (BabelPlugins.Count > 0) + plugins = ", plugins: " + JsonConvert.SerializeObject(BabelPlugins); + instance.Instance.SetVariable(RenderInputVariable, code); + if (!MinimiseBabelOutput) + instance.Instance.Execute( + TransformCodeScriptDebug + JsonConvert.SerializeObject(BabelPresets) + plugins + + (ParserOptions != null ? ParserOptionsConst + JsonConvert.SerializeObject(ParserOptions) : string.Empty) + + TransformCodeTrailer, ScriptName); + else + instance.Instance.Execute( + TransformCodeScript + JsonConvert.SerializeObject(BabelPresets) + plugins + + (ParserOptions != null ? ParserOptionsConst + JsonConvert.SerializeObject(ParserOptions) : string.Empty) + + TransformCodeTrailer, ScriptName); + toret = instance.Instance.GetVariable(RenderOutputVariable) as string; + } + } + catch (Exception e) { + Console.Error.WriteLine("20: Babel Transformation Error\n" + e.Message); + } + return toret; + } + static bool IsWindows => (Environment.OSVersion.Platform.ToString().StartsWith("W")); + } + public class Poolable : IDisposable + { + public ConcurrentQueue parent; + /// + /// Lifetime of any poolable objects in seconds. + /// A minute supposes short bursts of use, an hour (3600) supposes long periods of heavy use, 5 seconds for the memory conscious only + /// + public static int PoolLife { get; set; } = 60; + internal DateTime LastUsed; + public Poolable(JsContext obj, ConcurrentQueue Parent) + { + this.Instance = obj; + this.parent = Parent; + LastUsed = DateTime.UtcNow; + if (JsPool.UseMinimalMemory == true) { + System.Threading.Tasks.Task.Factory.StartNew(Conserve); + } + //parent.Pool.Enqueue(this); + } + internal async void Conserve() + { + while (parent != null) { + await System.Threading.Tasks.Task.Delay(PoolLife * 1000); + if (DateTime.UtcNow.Subtract(LastUsed).Seconds > PoolLife && parent.Count > JsPool.MinSize) { + Poolable throwaway; + parent.TryDequeue(out throwaway); + if (throwaway.Instance is IDisposable) + (throwaway.Instance as IDisposable).Dispose(); + throwaway = null; + } + } + } + public JsContext Instance { get; internal set; } + public void Dispose() + { + if (Instance != null && parent.Count < JsPool.MaxSize) + parent.Enqueue(this); + } + } + public class JsPool + { + /// + /// The maximum number allowable within the queue + /// Default is 100, it's unlikely to ever get that far + /// + public static int MaxSize { get; set; } = 100; //As of writing, the entire system responds in 10ms which means a max 100 requests a second + /// + /// The number of JsContexts in the queue, one is default and good for one user at one time + /// Three is ideal for busier sites + /// + public static int MinSize { get; set; } = 1; + /// + /// If over the minimum JsContext size and if set to true, JsContexts will begin deleting themselves after PoolLife seconds. + /// + public static bool UseMinimalMemory = true; + /// + /// Freely accessible JsPool actual pool, a queue, for mischief + /// + public ConcurrentQueue Pool; + private Func Generator; + /// + /// Input is a function that returns a string, this allows the JsPool to dynamically use script strings on regeneration + /// + /// + public JsPool(Func ScriptGenerator) + { + Pool = new ConcurrentQueue(); + Generator = () => { + string Script = ScriptGenerator(); + var dasengine = new JsEngine(-1, -1); + var newcontext = dasengine.CreateContext(); + if (!string.IsNullOrEmpty(Script)) + newcontext.Execute(Script); + return newcontext; + }; + Load(); + } + + private void Load() + { + for (int i = 0; i < MinSize; i++) + Pool.Enqueue(new Poolable(Generator(), Pool)); + } + /// + /// Don't use this. It's an extremely bad habit. + /// + public JsContext GetInstance => GetContext().Instance; + /// + /// Literally use this such as using(var Pool = JsPool.GetObject()) + /// + /// + public Poolable GetContext() + { + if (Pool != null && Pool.Count > 0) { + Poolable output; + if (Pool.TryDequeue(out output)) + return output; + } + return new Poolable(Generator(), Pool); + } + } +} \ No newline at end of file