diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..1b61e2d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,9 @@ +locals_without_parens = [ + plug: 1, + plug: 2 +] + +[ + inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: locals_without_parens +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 41a8de7..4eb90ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +* Support for esbuild + ### Changed * Updated poolboy, it itself now uses rebar3 to build. diff --git a/lib/app.ex b/lib/app.ex new file mode 100644 index 0000000..edb1246 --- /dev/null +++ b/lib/app.ex @@ -0,0 +1,36 @@ +defmodule Reaxt.App do + use Application + require Logger + + def start(_, _) do + hot_reload_processes = + if Reaxt.Utils.is_hot?() do + hot_processes(Reaxt.Utils.bundler()) + else + [] + end + + base_processes = [ + Reaxt.PoolsSup + ] + + children = Enum.concat(base_processes, hot_reload_processes) + + result = Supervisor.start_link(children, name: __MODULE__, strategy: :one_for_one) + if Reaxt.Utils.is_webpack?(), do: Reaxt.Index.Generator.build_webpack_stats() + + result + end + + def hot_processes(:webpack) do + [ + WebPack.Hot.Events, + WebPack.Hot.EventManager, + WebPack.Hot.Compiler + ] + end + + def hot_processes(_) do + raise "[Reaxt] Hot reload is not supported for the bundler #{inspect(Reaxt.Utils.bundler())}" + end +end diff --git a/lib/esbuild/esbuild.ex b/lib/esbuild/esbuild.ex new file mode 100644 index 0000000..0a955a3 --- /dev/null +++ b/lib/esbuild/esbuild.ex @@ -0,0 +1,9 @@ +defmodule Reaxt.Esbuild do + @moduledoc """ + Utilities functions to fetch specific esbuild configs + """ + + def esbuild_config do + Application.get_env(:reaxt, :esbuild_config, "build.js") + end +end diff --git a/lib/esbuild/tasks.esbuild.ex b/lib/esbuild/tasks.esbuild.ex new file mode 100644 index 0000000..6725be6 --- /dev/null +++ b/lib/esbuild/tasks.esbuild.ex @@ -0,0 +1,53 @@ +defmodule Mix.Tasks.Esbuild.Compile do + use Mix.Task + + @shortdoc "Compiles Esbuild" + + def run(_) do + {_logs, 0} = compile() + :ok + end + + def compile() do + config = "./" <> Reaxt.Esbuild.esbuild_config() + + System.cmd( + "node", + [config], + into: "", + cd: Reaxt.Utils.web_app(), + env: [{"MIX_ENV", "#{Mix.env()}"}] + ) + end +end + +defmodule Mix.Tasks.Compile.ReaxtEsbuild do + use Mix.Task.Compiler + + def run(args) do + IO.puts("[Reaxt] Running Esbuild compiler...") + Mix.Task.run("reaxt.validate", args ++ ["--reaxt-skip-compiler-check"]) + + if !File.exists?(Path.join(Reaxt.Utils.web_app(), "node_modules")) do + Mix.Task.run("npm.install", args) + else + installed_version = + Poison.decode!(File.read!("#{Reaxt.Utils.web_app()}/node_modules/reaxt/package.json"))[ + "version" + ] + + current_version = + Poison.decode!(File.read!("#{:code.priv_dir(:reaxt)}/commonjs_reaxt/package.json"))[ + "version" + ] + + if installed_version !== current_version, do: Mix.Task.run("npm.install", args) + end + + if !Reaxt.Utils.is_hot?() do + Mix.Task.run("esbuild.compile", args) + else + {:ok, []} + end + end +end diff --git a/lib/pool.ex b/lib/pool.ex new file mode 100644 index 0000000..02fabc3 --- /dev/null +++ b/lib/pool.ex @@ -0,0 +1,51 @@ +defmodule Reaxt.PoolsSup do + @moduledoc """ + Supervision of multiple :poolboy instances for the Server Side Rendering + """ + alias :poolboy, as: Pool + + use Supervisor + require Logger + + def transaction(pool_name, fct) do + Pool.transaction(pool_name, fct) + end + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + def init(_) do + pool_size = Reaxt.Utils.pool_size() + pool_overflow = Reaxt.Utils.max_pool_overflow() + server_dir = "#{Reaxt.Utils.web_priv()}/#{Reaxt.Utils.server_dir()}" + server_files = Path.wildcard("#{server_dir}/*.js") + + if server_files == [] do + Logger.error( + "#server JS not yet compiled in #{server_dir}, compile it before with `mix webpack.compile`" + ) + + throw({:error, :serverjs_not_compiled}) + else + children = + Enum.map(server_files, fn server -> + parsed_js_name = + server |> Path.basename(".js") |> String.replace(~r/[0-9][a-z][A-Z]/, "_") + + pool_name = :"react_#{parsed_js_name}_pool" + + args = [ + worker_module: Reaxt.Render, + size: pool_size, + max_overflow: pool_overflow, + name: {:local, pool_name} + ] + + Pool.child_spec(pool_name, args, server) + end) + + Supervisor.init(children, strategy: :one_for_one) + end + end +end diff --git a/lib/reaxt.ex b/lib/reaxt.ex deleted file mode 100644 index 02e036a..0000000 --- a/lib/reaxt.ex +++ /dev/null @@ -1,110 +0,0 @@ -defmodule ReaxtError do - defexception [:message,:args,:js_render,:js_stack] - def exception({:handler_error,module,submodule,args,error,stack}) do - params=%{ - module: module, - submodule: submodule, - args: args - } - %ReaxtError{message: "JS Handler Exception for #{inspect params}: #{error}", args: params, js_stack: (stack && parse_stack(stack))} - end - def exception({:render_error,params,error,stack,js_render}) do - %ReaxtError{message: "JS Render Exception : #{error}", args: params, js_render: js_render, js_stack: (stack && parse_stack(stack))} - end - defp parse_stack(stack) do - Regex.scan(~r/at (.*) \((.*):([0-9]*):[0-9]*\)/,stack) - |> Enum.map(fn [_,function,url,line]-> - if String.contains?(url,"/priv") and !(function in ["Port.next_term","Socket.read_term"]) do - {line,_} = Integer.parse(line) - [_,after_priv] = String.split(url,"/priv/",parts: 2) - {JS,:"#{function}",0,file: '#{WebPack.Util.web_priv}/#{after_priv}', line: line} - end - end) - |> Enum.filter(&!is_nil(&1)) - end -end -defmodule Reaxt do - alias :poolboy, as: Pool - require Logger - - def render_result(chunk,module,data,timeout) when not is_tuple(module), do: - render_result(chunk,{module,nil},data,timeout) - def render_result(chunk,{module,submodule},data,timeout) do - Pool.transaction(:"react_#{chunk}_pool",fn worker-> - GenServer.call(worker,{:render,module,submodule,data,timeout},timeout+100) - end) - end - - def render!(module,data,timeout \\ 5_000, chunk \\ :server) do - case render_result(chunk,module,data,timeout) do - {:ok,res}->res - {:error,err}-> - try do raise(ReaxtError,err) - rescue ex-> - [_|stack] = __STACKTRACE__ - reraise ex, ((ex.js_stack || []) ++ stack) - end - end - end - - def render(module,data, timeout \\ 5_000) do - try do - render!(module,data,timeout) - rescue - ex-> - case ex do - %{js_render: js_render} when is_binary(js_render)-> - Logger.error(Exception.message(ex)) - %{css: "",html: "", js_render: js_render} - _ -> - reraise ex, __STACKTRACE__ - end - end - end - - def reload do - WebPack.Util.build_stats - Supervisor.terminate_child(Reaxt.App, Reaxt.App.PoolsSup) - Supervisor.restart_child(Reaxt.App, Reaxt.App.PoolsSup) - end - - def start_link(server_path) do - init = Poison.encode!(Application.get_env(:reaxt,:global_config,nil)) - Exos.Proc.start_link("node #{server_path}",init,[cd: '#{WebPack.Util.web_priv}']) - end - - defmodule App do - use Application - def start(_,_) do - result = Supervisor.start_link( - [App.PoolsSup] ++ List.wrap(if Application.get_env(:reaxt,:hot) do [ - WebPack.Events, - WebPack.EventManager, - WebPack.Compiler, - #{WebPack.StartBlocker,:infinity} # choice : wait for build or "mix webpack.compile" before launch - ] end), name: __MODULE__, strategy: :one_for_one) - WebPack.Util.build_stats - result - end - defmodule PoolsSup do - use Supervisor - def start_link(arg) do Supervisor.start_link(__MODULE__,arg, name: __MODULE__) end - def init(_) do - pool_size = Application.get_env(:reaxt,:pool_size) - pool_overflow = Application.get_env(:reaxt,:pool_max_overflow) - server_dir = "#{WebPack.Util.web_priv}/#{Application.get_env(:reaxt,:server_dir)}" - server_files = Path.wildcard("#{server_dir}/*.js") - if server_files == [] do - Logger.error("#server JS not yet compiled in #{server_dir}, compile it before with `mix webpack.compile`") - throw {:error,:serverjs_not_compiled} - else - Supervisor.init( - for server<-server_files do - pool = :"react_#{server |> Path.basename(".js") |> String.replace(~r/[0-9][a-z][A-Z]/,"_")}_pool" - Pool.child_spec(pool,[worker_module: Reaxt,size: pool_size, max_overflow: pool_overflow, name: {:local,pool}], server) - end, strategy: :one_for_one) - end - end - end - end -end diff --git a/lib/reaxt_error.ex b/lib/reaxt_error.ex new file mode 100644 index 0000000..b78459e --- /dev/null +++ b/lib/reaxt_error.ex @@ -0,0 +1,50 @@ +defmodule Reaxt.Error do + @moduledoc """ + Exception representing a Reaxt Error + """ + defexception [:message, :args, :js_render, :js_stack] + + def exception({:handler_error, module, submodule, args, error, stack}) do + params = %{ + module: module, + submodule: submodule, + args: args + } + + %Reaxt.Error{ + message: "JS Handler Exception for #{inspect(params)}: #{error}", + args: params, + js_stack: stack && parse_stack(stack) + } + end + + def exception({:render_error, params, error, stack, js_render}) do + %Reaxt.Error{ + message: "JS Render Exception : #{error}", + args: params, + js_render: js_render, + js_stack: stack && parse_stack(stack) + } + end + + def exception(rest) do + %Reaxt.Error{ + message: "JS Render Exception : #{inspect(rest)}", + args: "", + js_render: "", + js_stack: "" + } + end + + defp parse_stack(stack) do + Regex.scan(~r/at (.*) \((.*):([0-9]*):[0-9]*\)/, stack) + |> Enum.filter(fn [_, function, url, _line] -> + String.contains?(url, "/priv") and function not in ["Port.next_term", "Socket.read_term"] + end) + |> Enum.map(fn [_, function, url, line] -> + {line, _} = Integer.parse(line) + [_, after_priv] = String.split(url, "/priv/", parts: 2) + {JS, :"#{function}", 0, file: ~c"#{Reaxt.Utils.web_priv()}/#{after_priv}", line: line} + end) + end +end diff --git a/lib/reaxt_index.ex b/lib/reaxt_index.ex new file mode 100644 index 0000000..f58ac7c --- /dev/null +++ b/lib/reaxt_index.ex @@ -0,0 +1,64 @@ +defmodule Reaxt.Index.Generator do + @moduledoc """ + Utils functions to overlaod the Reaxt.Index module for the WebPack configuration + """ + + def build_webpack_stats do + if File.exists?("#{Reaxt.Utils.web_priv()}/webpack.stats.json") do + all_stats = Poison.decode!(File.read!("#{Reaxt.Utils.web_priv()}/webpack.stats.json")) + stats_array = all_stats["children"] + + stats = + Enum.map(stats_array, fn stats -> + %{ + assetsByChunkName: stats["assetsByChunkName"], + errors: stats["errors"], + warnings: stats["warnings"] + } + end) + + _ = Code.compiler_options(ignore_module_conflict: true) + + defmodule Elixir.Reaxt.Index do + @stats stats + def stats, do: @stats + + def file_of(name) do + r = + Enum.find_value(WebPack.stats(), fn %{assetsByChunkName: assets} -> + assets["#{name}"] + end) + + case r do + [f | _] -> f + f -> f + end + end + + @header_script if(Reaxt.Utils.is_hot?(), + do: ~s() + ) + @header_global Poison.encode!(Reaxt.Render.get_global_config()) + + def header do + "\n#{@header_script}" + end + end + + _ = Code.compiler_options(ignore_module_conflict: false) + end + end +end + +defmodule Elixir.Reaxt.Index do + @moduledoc """ + Functions to help construct the index.html to serve + """ + def stats, do: %{assetsByChunkName: %{}} + def file_of(_), do: nil + + def header do + global_config = Poison.encode!(Reaxt.Render.get_global_config()) + "" + end +end diff --git a/lib/render.ex b/lib/render.ex new file mode 100644 index 0000000..7f408db --- /dev/null +++ b/lib/render.ex @@ -0,0 +1,65 @@ +defmodule Reaxt.Render do + require Logger + + def get_global_config do + Application.get_env(:reaxt, :global_config, %{}) + end + + def set_global_config(config) do + Application.put_env(:reaxt, :global_config, config) + end + + def render_result(chunk, module, data, timeout) when not is_tuple(module) do + render_result(chunk, {module, nil}, data, timeout) + end + + def render_result(chunk, {module, submodule}, data, timeout) do + Reaxt.PoolsSup.transaction(:"react_#{chunk}_pool", fn worker -> + GenServer.call(worker, {:render, module, submodule, data, timeout}, timeout + 100) + end) + end + + def render!(module, data, timeout \\ 5_000, chunk \\ :server) do + case render_result(chunk, module, data, timeout) do + {:ok, res} -> + res + + {:error, err} -> + try do + raise(Reaxt.Error, err) + rescue + ex -> + [_ | stack] = __STACKTRACE__ + # stack = List.wrap(ex[:js_stack]) |> Enum.concat(stack) + reraise ex, stack + end + end + end + + def render(module, data, timeout \\ 5_000) do + try do + render!(module, data, timeout) + rescue + ex -> + case ex do + %{js_render: js_render} when is_binary(js_render) -> + Logger.error(Exception.message(ex)) + %{css: "", html: "", js_render: js_render} + + _ -> + reraise ex, __STACKTRACE__ + end + end + end + + def reload do + if Reaxt.Utils.is_webpack?(), do: Reaxt.Index.Generator.build_webpack_stats() + :ok = Supervisor.terminate_child(Reaxt.App, Reaxt.PoolsSup) + Supervisor.restart_child(Reaxt.App, Reaxt.PoolsSup) + end + + def start_link(server_path) do + init = Poison.encode!(Application.get_env(:reaxt, :global_config, nil)) + Exos.Proc.start_link("node #{server_path}", init, cd: ~c"#{Reaxt.Utils.web_priv()}") + end +end diff --git a/lib/tasks.ex b/lib/tasks.ex deleted file mode 100644 index bcf608a..0000000 --- a/lib/tasks.ex +++ /dev/null @@ -1,148 +0,0 @@ -defmodule Mix.Tasks.Npm.Install do - use Mix.Task - - @shortdoc "`npm install` in web_dir + npm install server side dependencies" - def run(_args) do - System.cmd("npm",["install"], into: IO.stream(:stdio, :line), cd: WebPack.Util.web_app) - # TOIMPROVE- did not found a better hack to avoid npm install symlink : first make a tar gz package, then npm install it - reaxt_tgz = "#{System.tmp_dir}/reaxt.tgz" - System.cmd("tar", ["zcf",reaxt_tgz,"commonjs_reaxt"],into: IO.stream(:stdio, :line), cd: "#{:code.priv_dir(:reaxt)}") - System.cmd("npm",["install","--no-save",reaxt_tgz], into: IO.stream(:stdio, :line), cd: WebPack.Util.web_app) - end -end - -defmodule Mix.Tasks.Webpack.Analyseapp do - use Mix.Task - - @shortdoc "Generate webpack stats analysing application, resulting priv/static is meant to be versionned" - def run(_args) do - File.rm_rf!("priv/static") - {_,0} = System.cmd("git",["clone","-b","ajax-sse-loading","https://github.com/awetzel/analyse"], into: IO.stream(:stdio, :line)) - {_,0} = System.cmd("npm",["install"], into: IO.stream(:stdio, :line), cd: "analyse") - {_,0} = System.cmd("grunt",[], into: IO.stream(:stdio, :line), cd: "analyse") - File.cp_r!("analyse/dist", "priv/static") - File.rm_rf!("analyse") - end -end - -defmodule Mix.Tasks.Webpack.Compile do - use Mix.Task - - @shortdoc "Compiles Webpack" - @webpack "./node_modules/webpack/bin/webpack.js" - - def run(_) do - case compile() do - {json, 0} -> - File.write!("priv/webpack.stats.json", json) - {:ok, []} - - {ret, x} when x in [1,2] -> - require Logger - ret - |> Poison.decode!() - |> Map.fetch!("errors") - |> Enum.map(fn - bin when is_binary(bin) -> Logger.error(bin) - %{"message" => bin} when is_binary(bin) -> Logger.error(bin) - end) - {:error,[]} - end - end - - def compile() do - config = "./"<>WebPack.Util.webpack_config - webpack = @webpack - System.cmd( - "node", - [webpack, "--config", config, "--json"], - into: "", - cd: WebPack.Util.web_app(), - env: [{"MIX_ENV", "#{Mix.env()}"}] - ) - end -end - -defmodule Mix.Tasks.Reaxt.Validate do - use Mix.Task - - @shortdoc "Validates that reaxt is setup correct" - def run(args) do - if Enum.all?(args, &(&1 != "--reaxt-skip-validation")) do - validate(args) - end - end - - def validate(args) do - if WebPack.Util.web_priv == :no_app_specified, do: - Mix.raise """ - Reaxt :otp_app is not configured. - Add following to config.exs - - config :reaxt, :otp_app, :your_app - - """ - - packageJsonPath = Path.join(WebPack.Util.web_app, "package.json") - if not File.exists?(packageJsonPath), do: - Mix.raise """ - Reaxt could not find a package.json in #{WebPack.Util.web_app}. - Add package.json to #{WebPack.Util.web_app} or configure a new - web_app directory in config.exs: - - config :reaxt, :web_app, "webapp" - - """ - - packageJson = Poison.decode!(File.read!(packageJsonPath)) - if packageJson["devDependencies"]["webpack"] == nil, do: - Mix.raise """ - Reaxt requires webpack as a devDependency in #{packageJsonPath}. - Add a dependency to 'webpack' like: - - { - devDependencies: { - "webpack": "^1.4.13" - } - } - """ - - if (Enum.all?(args, &(&1 != "--reaxt-skip-compiler-check")) - and Enum.all? (Mix.Project.get!).project[:compilers], &(&1 != :reaxt_webpack)), do: - Mix.raise """ - Reaxt has a built in compiler that compiles the web app. - Remember to add it to the list of compilers in mix.exs: - - def project do - [... - app: :your_app, - compilers: [:reaxt_webpack] ++ Mix.compilers, - ...] - end - """ - end -end - -defmodule Mix.Tasks.Compile.ReaxtWebpack do - use Mix.Task.Compiler - - def run(args) do - IO.puts("[Reaxt] Running compiler...") - Mix.Task.run("reaxt.validate", args ++ ["--reaxt-skip-compiler-check"]) - - if !File.exists?(Path.join(WebPack.Util.web_app, "node_modules")) do - Mix.Task.run("npm.install", args) - else - installed_version = Poison.decode!(File.read!("#{WebPack.Util.web_app}/node_modules/reaxt/package.json"))["version"] - current_version = Poison.decode!(File.read!("#{:code.priv_dir(:reaxt)}/commonjs_reaxt/package.json"))["version"] - if installed_version !== current_version, do: - Mix.Task.run("npm.install", args) - end - - if !Application.get_env(:reaxt,:hot) do - Mix.Task.run("webpack.compile", args) - else - {:ok, []} - end - end -end diff --git a/lib/tasks.reaxt.ex b/lib/tasks.reaxt.ex new file mode 100644 index 0000000..2960083 --- /dev/null +++ b/lib/tasks.reaxt.ex @@ -0,0 +1,103 @@ +defmodule Mix.Tasks.Npm.Install do + @moduledoc """ + Mix task to install npm dependencies and the reaxt js server + """ + use Mix.Task + + @shortdoc "`npm install` in web_dir + npm install server side dependencies" + def run(_args) do + System.cmd("npm", ["install"], into: IO.stream(:stdio, :line), cd: Reaxt.Utils.web_app()) + + # TOIMPROVE- did not found a better hack to avoid npm install symlink : first make a tar gz package, then npm install it + reaxt_tgz = "#{System.tmp_dir()}/reaxt.tgz" + + System.cmd("tar", ["zcf", reaxt_tgz, "commonjs_reaxt"], + into: IO.stream(:stdio, :line), + cd: "#{:code.priv_dir(:reaxt)}" + ) + + System.cmd("npm", ["install", "--no-save", reaxt_tgz], + into: IO.stream(:stdio, :line), + cd: Reaxt.Utils.web_app() + ) + end +end + +defmodule Mix.Tasks.Reaxt.Validate do + use Mix.Task + + @shortdoc "Validates that reaxt is setup correct" + def run(args) do + if Enum.all?(args, &(&1 != "--reaxt-skip-validation")) do + validate(args) + end + end + + def validate(args) do + if Reaxt.Utils.bundler() not in [:webpack, :esbuild], + do: + Mix.raise(""" + Reaxt :bundler is not configured. + Add following to config.exs + + config :reaxt, :bundler, :webpack + OR + config :reaxt, :bundler, :esbuild + """) + + if Reaxt.Utils.web_priv() == :no_app_specified, + do: + Mix.raise(""" + Reaxt :otp_app is not configured. + Add following to config.exs + + config :reaxt, :otp_app, :your_app + + """) + + packageJsonPath = Path.join(Reaxt.Utils.web_app(), "package.json") + + if not File.exists?(packageJsonPath), + do: + Mix.raise(""" + Reaxt could not find a package.json in #{Reaxt.Utils.web_app()}. + Add package.json to #{Reaxt.Utils.web_app()} or configure a new + web_app directory in config.exs: + + config :reaxt, :web_app, "webapp" + + """) + + packageJson = Poison.decode!(File.read!(packageJsonPath)) + + if packageJson["devDependencies"]["webpack"] == nil, + do: + Mix.raise(""" + Reaxt requires webpack as a devDependency in #{packageJsonPath}. + Add a dependency to 'webpack' like: + + { + devDependencies: { + "webpack": "^1.4.13" + } + } + """) + + good_compilers = [:reaxt_webpack, :reaxt_esbuild] + + if Enum.all?(args, &(&1 != "--reaxt-skip-compiler-check")) and + not Enum.any?(Mix.Project.get!().project[:compilers], &(&1 in good_compilers)), + do: + Mix.raise(""" + Reaxt has a built in compiler that compiles the web app. + Remember to add it to the list of compilers in mix.exs: + + def project do + [... + app: :your_app, + compilers: [:reaxt_webpack] ++ Mix.compilers, + ...] + end + """) + end +end diff --git a/lib/utils.ex b/lib/utils.ex new file mode 100644 index 0000000..46beeb9 --- /dev/null +++ b/lib/utils.ex @@ -0,0 +1,50 @@ +defmodule Reaxt.Utils do + @moduledoc """ + + """ + + @doc "Return the priv directory of the otp_app using Reaxt" + def web_priv do + case Application.get_env(:reaxt, :otp_app, :no_app_specified) do + :no_app_specified -> + :no_app_specified + + web_app -> + :code.priv_dir(web_app) + end + end + + @doc "Return the name of the directory containing the Web application" + def web_app do + Application.get_env(:reaxt, :web_app, "web") + end + + @doc "Return the bundler used by Reaxt. Default is :webpack" + def bundler() do + Application.get_env(:reaxt, :bundler, :webpack) + end + + @doc "Return true if the configured bundler is webpack" + def is_webpack?() do + bundler() == :webpack + end + + @doc "Return true if the Hot reload capacity is enabled" + def is_hot?() do + Application.get_env(:reaxt, :hot, false) + end + + @doc "Return the path to the react_servers directory" + def server_dir() do + Application.get_env(:reaxt, :server_dir, "react_servers") + end + + @doc "Return the size of the Poolboy pool for SSR" + def pool_size() do + Application.get_env(:reaxt, :pool_size, 1) + end + + def max_pool_overflow() do + Application.get_env(:reaxt, :pool_max_overflow, 5) + end +end diff --git a/lib/webpack.ex b/lib/webpack.ex deleted file mode 100644 index aec51d3..0000000 --- a/lib/webpack.ex +++ /dev/null @@ -1,226 +0,0 @@ -defmodule WebPack.Events do - def child_spec(_) do - Registry.child_spec(keys: :duplicate, name: __MODULE__) - end - - @dispatch_key :events - def register! do - {:ok, _} = Registry.register(__MODULE__,@dispatch_key,nil) - end - def dispatch(event) do - Registry.dispatch(__MODULE__, @dispatch_key, fn entries -> - for {pid, nil} <- entries, do: send(pid,{:event,event}) - end) - end - - import Plug.Conn - def stream_chunks(conn) do - register!() - conn = Stream.repeatedly(fn-> receive do {:event,event}-> event end end) - |> Enum.reduce_while(conn, fn event, conn -> - io = "event: #{event.event}\ndata: #{Poison.encode!(event)}\n\n" - case chunk(conn,io) do {:ok,conn}->{:cont,conn};{:error,:closed}->{:halt,conn} end - end) - halt(conn) - end -end - -defmodule WebPack.Plug.Static do - @moduledoc """ - This plug API is the same as plug.static, - but wrapped to : - - wait file if compiling before serving them - - add server side event endpoint for webpack build events - - add webpack "stats" JSON getter, and stats static analyser app - """ - use Plug.Router - plug :match - plug :dispatch - plug Plug.Static, at: "/webpack/static", from: :reaxt - plug :wait_compilation - - def init(static_opts), do: Plug.Static.init(static_opts) - def call(conn, opts) do - conn = plug_builder_call(conn, opts) - if !conn.halted, do: static_plug(conn,opts), else: conn - end - - def wait_compilation(conn,_) do - if Application.get_env(:reaxt,:hot) do - try do - :ok = GenServer.call(WebPack.EventManager,:wait?,30_000) - catch - :exit,{:timeout,_} -> :ok - end - end - conn - end - - def static_plug(conn,static_opts) do - Plug.Static.call(conn,static_opts) - end - - get "/webpack/stats.json" do - conn - |> put_resp_content_type("application/json") - |> send_file(200,"#{WebPack.Util.web_priv}/webpack.stats.json") - |> halt - end - get "/webpack", do: %{conn|path_info: ["webpack","static","index.html"]} - get "/webpack/events" do - conn=conn - |> put_resp_header("content-type", "text/event-stream") - |> send_chunked(200) - hot? = Application.get_env(:reaxt,:hot) - if hot? == :client, do: chunk(conn, "event: hot\ndata: nothing\n\n") - if hot? do WebPack.Events.stream_chunks(conn) else conn end - end - get "/webpack/client.js" do - conn - |> put_resp_content_type("application/javascript") - |> send_file(200,"#{WebPack.Util.web_app}/node_modules/reaxt/webpack_client.js") - |> halt - end - match _, do: conn - -end - -defmodule WebPack.StartBlocker do - @moduledoc """ - this process blocks application start to ensure that when the next application starts - the reaxt render is ready (js is compiled) - """ - def child_spec(arg) do - %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}, restart: :temporary} - end - def start_link(timeout) do :proc_lib.start_link(__MODULE__,:wait, [timeout]) end - def wait(timeout) do - :ok = GenServer.call(WebPack.EventManager,:wait?,timeout) - :proc_lib.init_ack({:ok,self()}) - end -end - -defmodule WebPack.EventManager do - use GenServer - require Logger - def start_link(_) do GenServer.start_link(__MODULE__,[], name: __MODULE__) end - - def init([]) do - WebPack.Events.register!() - {:ok,%{init: true,pending: [], compiling: false, compiled: false}} - end - - def handle_call(:wait?,_from,%{compiling: false}=state), do: - {:reply,:ok,state} - def handle_call(:wait?,from,state), do: - {:noreply,%{state|pending: [from|state.pending]}} - - def handle_info({:event,%{event: "client_done"}=ev},state) do - Logger.info("[reaxt-webpack] client done, build_stats") - WebPack.Util.build_stats - if(!state.init) do - Logger.info("[reaxt-webpack] client done, restart servers") - :ok = Supervisor.terminate_child(Reaxt.App, Reaxt.App.PoolsSup) - {:ok,_} = Supervisor.restart_child(Reaxt.App, Reaxt.App.PoolsSup) - end - _ = case ev do - %{error: "soft fail", error_details: details} -> - _ = Logger.error("[reaxt-webpack] soft fail compiling server_side JS") - _ = Enum.each(details.errors, fn - bin when is_binary(bin) -> Logger.error(bin) - %{message: bin} when is_binary(bin) -> Logger.error(bin) - end) - - %{error: other} -> - _ = Logger.error("[reaxt-webpack] error compiling server_side JS : #{other}") - _ = System.halt(1) - - _ -> :ok - end - for {_idx,build}<-WebPack.stats, error<-build.errors, do: Logger.warning(error) - for {_idx,build}<-WebPack.stats, warning<-build.warnings, do: Logger.warning(warning) - {:noreply,done(state)} - end - - def handle_info({:event,%{event: "client_invalid"}},%{compiling: false}=state) do - Logger.info("[reaxt-webpack] detect client file change") - {:noreply,%{state|compiling: true, compiled: false}} - end - def handle_info({:event,%{event: "done"}},state) do - Logger.info("[reaxt-webpack] both done !") - {:noreply,state} - end - def handle_info({:event,ev},state) do - Logger.info("[reaxt-webpack] event : #{ev[:event]}") - {:noreply,state} - end - - def done(state) do - for from<-state.pending do GenServer.reply(from,:ok) end - WebPack.Events.dispatch(%{event: "done"}) - %{state| pending: [], init: false, compiling: false, compiled: true} - end -end - -defmodule WebPack.Compiler do - def child_spec(arg) do - %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]} } - end - def start_link(_) do - cmd = "node ./node_modules/reaxt/webpack_server #{WebPack.Util.webpack_config}" - hot_arg = if Application.get_env(:reaxt,:hot) == :client, do: " hot",else: "" - Exos.Proc.start_link(cmd<>hot_arg,[],[cd: WebPack.Util.web_app],[name: __MODULE__],&WebPack.Events.dispatch/1) - end -end - -defmodule WebPack.Util do - def webpack_config do - Application.get_env(:reaxt,:webpack_config,"webpack.config.js") - end - - def web_priv do - case Application.get_env :reaxt, :otp_app, :no_app_specified do - :no_app_specified -> :no_app_specified - web_app -> :code.priv_dir(web_app) - end - end - - def web_app do - Application.get_env :reaxt, :web_app, "web" - end - - def build_stats do - if File.exists?("#{web_priv()}/webpack.stats.json") do - all_stats = Poison.decode!(File.read!("#{web_priv()}/webpack.stats.json")) - stats_array = all_stats["children"] - stats = Enum.map(stats_array,fn stats-> - %{assetsByChunkName: stats["assetsByChunkName"], - errors: stats["errors"], - warnings: stats["warnings"]} - end) - _ = Code.compiler_options(ignore_module_conflict: true) - defmodule Elixir.WebPack do - @stats stats - def stats, do: @stats - def file_of(name) do - r = Enum.find_value(WebPack.stats, fn %{assetsByChunkName: assets}-> assets["#{name}"] end) - case r do - [f|_]->f - f -> f - end - end - @header_script if(Application.get_env(:reaxt,:hot), do: ~s()) - @header_global Poison.encode!(Application.get_env(:reaxt,:global_config)) - def header, do: - "\n#{@header_script}" - end - _ = Code.compiler_options(ignore_module_conflict: false) - end - end -end - -defmodule Elixir.WebPack do - def stats, do: %{assetsByChunkName: %{}} - def file_of(_), do: nil - def header, do: "" -end diff --git a/lib/webpack/tasks.webpack.ex b/lib/webpack/tasks.webpack.ex new file mode 100644 index 0000000..5612c29 --- /dev/null +++ b/lib/webpack/tasks.webpack.ex @@ -0,0 +1,90 @@ +defmodule Mix.Tasks.Webpack.Analyseapp do + use Mix.Task + + @shortdoc "Generate webpack stats analysing application, resulting priv/static is meant to be versionned" + def run(_args) do + File.rm_rf!("priv/static") + + {_, 0} = + System.cmd("git", ["clone", "-b", "ajax-sse-loading", "https://github.com/awetzel/analyse"], + into: IO.stream(:stdio, :line) + ) + + {_, 0} = System.cmd("npm", ["install"], into: IO.stream(:stdio, :line), cd: "analyse") + {_, 0} = System.cmd("grunt", [], into: IO.stream(:stdio, :line), cd: "analyse") + File.cp_r!("analyse/dist", "priv/static") + File.rm_rf!("analyse") + end +end + +defmodule Mix.Tasks.Webpack.Compile do + use Mix.Task + + @shortdoc "Compiles Webpack" + @webpack "./node_modules/webpack/bin/webpack.js" + + def run(_) do + case compile() do + {json, 0} -> + File.write!("priv/webpack.stats.json", json) + {:ok, []} + + {ret, x} when x in [1, 2] -> + require Logger + + ret + |> Poison.decode!() + |> Map.fetch!("errors") + |> Enum.map(fn + bin when is_binary(bin) -> Logger.error(bin) + %{"message" => bin} when is_binary(bin) -> Logger.error(bin) + end) + + {:error, []} + end + end + + def compile() do + config = "./" <> Reaxt.Webpack.webpack_config() + webpack = @webpack + + System.cmd( + "node", + [webpack, "--config", config, "--json"], + into: "", + cd: Reaxt.Utils.web_app(), + env: [{"MIX_ENV", "#{Mix.env()}"}] + ) + end +end + +defmodule Mix.Tasks.Compile.ReaxtWebpack do + use Mix.Task.Compiler + + def run(args) do + IO.puts("[Reaxt] Running Webpack compiler...") + Mix.Task.run("reaxt.validate", args ++ ["--reaxt-skip-compiler-check"]) + + if !File.exists?(Path.join(Reaxt.Utils.web_app(), "node_modules")) do + Mix.Task.run("npm.install", args) + else + installed_version = + Poison.decode!(File.read!("#{Reaxt.Utils.web_app()}/node_modules/reaxt/package.json"))[ + "version" + ] + + current_version = + Poison.decode!(File.read!("#{:code.priv_dir(:reaxt)}/commonjs_reaxt/package.json"))[ + "version" + ] + + if installed_version !== current_version, do: Mix.Task.run("npm.install", args) + end + + if !Application.get_env(:reaxt, :hot) do + Mix.Task.run("webpack.compile", args) + else + {:ok, []} + end + end +end diff --git a/lib/webpack/webpack.ex b/lib/webpack/webpack.ex new file mode 100644 index 0000000..e513195 --- /dev/null +++ b/lib/webpack/webpack.ex @@ -0,0 +1,238 @@ +defmodule Reaxt.Webpack do + @moduledoc """ + Utilities functions to fetch specific Webpack configs + """ + + def webpack_config do + Application.get_env(:reaxt, :webpack_config, "webpack.config.js") + end +end + +defmodule WebPack.Hot.Events do + def child_spec(_) do + Registry.child_spec(keys: :duplicate, name: __MODULE__) + end + + @dispatch_key :events + def register! do + {:ok, _} = Registry.register(__MODULE__, @dispatch_key, nil) + end + + def dispatch(event) do + Registry.dispatch(__MODULE__, @dispatch_key, fn entries -> + for {pid, nil} <- entries, do: send(pid, {:event, event}) + end) + end + + import Plug.Conn + + def stream_chunks(conn) do + register!() + + conn = + Stream.repeatedly(fn -> + receive do + {:event, event} -> event + end + end) + |> Enum.reduce_while(conn, fn event, conn -> + io = "event: #{event.event}\ndata: #{Poison.encode!(event)}\n\n" + + case chunk(conn, io) do + {:ok, conn} -> {:cont, conn} + {:error, :closed} -> {:halt, conn} + end + end) + + halt(conn) + end +end + +defmodule WebPack.Plug.Static do + @moduledoc """ + This plug API is the same as plug.static, + but wrapped to : + - wait file if compiling before serving them + - add server side event endpoint for webpack build events + - add webpack "stats" JSON getter, and stats static analyser app + """ + use Plug.Router + plug :match + plug :dispatch + plug Plug.Static, at: "/webpack/static", from: :reaxt + plug :wait_compilation + + def init(static_opts), do: Plug.Static.init(static_opts) + + def call(conn, opts) do + conn = plug_builder_call(conn, opts) + if !conn.halted, do: static_plug(conn, opts), else: conn + end + + def wait_compilation(conn, _) do + if Application.get_env(:reaxt, :hot) do + try do + :ok = GenServer.call(WebPack.Hot.EventManager, :wait?, 30_000) + catch + :exit, {:timeout, _} -> :ok + end + end + + conn + end + + def static_plug(conn, static_opts) do + Plug.Static.call(conn, static_opts) + end + + get "/webpack/stats.json" do + conn + |> put_resp_content_type("application/json") + |> send_file(200, "#{Reaxt.Utils.web_priv()}/webpack.stats.json") + |> halt + end + + get("/webpack", do: %{conn | path_info: ["webpack", "static", "index.html"]}) + + get "/webpack/events" do + conn = + conn + |> put_resp_header("content-type", "text/event-stream") + |> send_chunked(200) + + hot? = Reaxt.Utils.is_hot?() + if hot? == :client, do: chunk(conn, "event: hot\ndata: nothing\n\n") + + if hot? do + WebPack.Hot.Events.stream_chunks(conn) + else + conn + end + end + + get "/webpack/client.js" do + conn + |> put_resp_content_type("application/javascript") + |> send_file(200, "#{Reaxt.Utils.web_app()}/node_modules/reaxt/webpack_client.js") + |> halt + end + + match(_, do: conn) +end + +defmodule WebPack.StartBlocker do + @moduledoc """ + this process blocks application start to ensure that when the next application starts + the reaxt render is ready (js is compiled) + """ + def child_spec(arg) do + %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}, restart: :temporary} + end + + def start_link(timeout) do + :proc_lib.start_link(__MODULE__, :wait, [timeout]) + end + + def wait(timeout) do + :ok = GenServer.call(WebPack.EventManager, :wait?, timeout) + :proc_lib.init_ack({:ok, self()}) + end +end + +defmodule WebPack.Hot.EventManager do + use GenServer + require Logger + + def start_link(_) do + GenServer.start_link(__MODULE__, [], name: __MODULE__) + end + + def init([]) do + WebPack.Hot.Events.register!() + {:ok, %{init: true, pending: [], compiling: false, compiled: false}} + end + + def handle_call(:wait?, _from, %{compiling: false} = state), do: {:reply, :ok, state} + def handle_call(:wait?, from, state), do: {:noreply, %{state | pending: [from | state.pending]}} + + def handle_info({:event, %{event: "client_done"} = ev}, state) do + Logger.info("[reaxt-webpack] client done, build_stats") + _ = Reaxt.Index.Generator.build_webpack_stats() + + if(!state.init) do + Logger.info("[reaxt-webpack] client done, restart servers") + :ok = Supervisor.terminate_child(Reaxt.App, Reaxt.PoolsSup) + {:ok, _} = Supervisor.restart_child(Reaxt.App, Reaxt.PoolsSup) + end + + _ = + case ev do + %{error: "soft fail", error_details: details} -> + _ = Logger.error("[reaxt-webpack] soft fail compiling server_side JS") + + _ = + Enum.each(details.errors, fn + bin when is_binary(bin) -> Logger.error(bin) + %{message: bin} when is_binary(bin) -> Logger.error(bin) + end) + + %{error: other} -> + _ = Logger.error("[reaxt-webpack] error compiling server_side JS : #{other}") + _ = System.halt(1) + + _ -> + :ok + end + + for {_idx, build} <- Reaxt.Index.stats(), error <- build.errors, do: Logger.warning(error) + + for {_idx, build} <- Reaxt.Index.stats(), + warning <- build.warnings, + do: Logger.warning(warning) + + {:noreply, done(state)} + end + + def handle_info({:event, %{event: "client_invalid"}}, %{compiling: false} = state) do + Logger.info("[reaxt-webpack] detect client file change") + {:noreply, %{state | compiling: true, compiled: false}} + end + + def handle_info({:event, %{event: "done"}}, state) do + Logger.info("[reaxt-webpack] both done !") + {:noreply, state} + end + + def handle_info({:event, ev}, state) do + Logger.info("[reaxt-webpack] event : #{ev[:event]}") + {:noreply, state} + end + + def done(state) do + for from <- state.pending do + GenServer.reply(from, :ok) + end + + WebPack.Hot.Events.dispatch(%{event: "done"}) + %{state | pending: [], init: false, compiling: false, compiled: true} + end +end + +defmodule WebPack.Hot.Compiler do + def child_spec(arg) do + %{id: __MODULE__, start: {__MODULE__, :start_link, [arg]}} + end + + def start_link(_) do + cmd = "node ./node_modules/reaxt/webpack_hot_server #{Reaxt.Webpack.webpack_config()}" + hot_arg = if Application.get_env(:reaxt, :hot) == :client, do: " hot", else: "" + + Exos.Proc.start_link( + cmd <> hot_arg, + [], + [cd: Reaxt.Utils.web_app()], + [name: __MODULE__], + &WebPack.Hot.Events.dispatch/1 + ) + end +end diff --git a/mix.exs b/mix.exs index cb84dd6..9636eec 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Reaxt.Mixfile do use Mix.Project - def version, do: "4.1.0" + def version, do: "5.0.0" defp description do """ @@ -18,29 +18,22 @@ defmodule Reaxt.Mixfile do elixir: "~> 1.12", deps: deps(), docs: docs(), - source_url: git_repository(), + source_url: git_repository() ] end def application do - [applications: [:logger, :poolboy, :exos, :plug, :poison], - mod: {Reaxt.App,[]}, - env: [ - otp_app: :reaxt, #the OTP application containing compiled JS server - hot: false, # false | true | :client hot compilation and loading - pool_size: 1, #pool size of react renderes - webpack_config: "webpack.config.js", - server_dir: "react_servers", - pool_max_overflow: 5 #maximum pool extension when the pool is full - ]] + [applications: [:logger, :poolboy, :exos, :plug, :poison], mod: {Reaxt.App, []}] end defp deps do - [{:exos, "~> 2.0"}, - {:poolboy, "~> 1.5"}, - {:plug, "~> 1.15"}, - {:poison,"~> 5.0"}, - {:ex_doc, "~> 0.31", only: :dev, runtime: false}] + [ + {:exos, "~> 2.0"}, + {:poolboy, "~> 1.5"}, + {:plug, "~> 1.15"}, + {:poison, "~> 5.0"}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false} + ] end defp package do @@ -48,9 +41,9 @@ defmodule Reaxt.Mixfile do licenses: ["The MIT License (MIT)"], links: %{ "GitHub" => git_repository(), - "Changelog" => "https://hexdocs.pm/reaxt/changelog.html", + "Changelog" => "https://hexdocs.pm/reaxt/changelog.html" }, - maintainers: ["Arnaud Wetzel"], + maintainers: ["Arnaud Wetzel"] ] end @@ -58,12 +51,12 @@ defmodule Reaxt.Mixfile do [ extras: [ "CHANGELOG.md": [title: "Changelog"], - "README.md": [title: "Overview"], + "README.md": [title: "Overview"] ], api_reference: false, main: "readme", source_url: git_repository(), - source_ref: "v#{version()}", + source_ref: "v#{version()}" ] end diff --git a/priv/commonjs_reaxt/client_entry_addition.js b/priv/commonjs_reaxt/client_entry_addition.js index 449bb4c..ebf9649 100644 --- a/priv/commonjs_reaxt/client_entry_addition.js +++ b/priv/commonjs_reaxt/client_entry_addition.js @@ -6,7 +6,7 @@ function default_client_render(props,render,param){ } window.reaxt_render = function(module,submodule,props,param){ - return import(`./../../components/${module}`).then((module)=>{ + return import(`./../../components/${module}.js`).then((module)=>{ module = module.default submodule = (submodule) ? module[submodule] :module submodule.reaxt_client_render = submodule.reaxt_client_render || default_client_render diff --git a/priv/commonjs_reaxt/react_server.js b/priv/commonjs_reaxt/react_server.js index a5ad7dc..8fe71f1 100644 --- a/priv/commonjs_reaxt/react_server.js +++ b/priv/commonjs_reaxt/react_server.js @@ -57,7 +57,7 @@ Server(function(term,from,state,done){ done("reply",Bert.tuple(Bert.atom("error"),Bert.tuple(Bert.atom("handler_error"),module,submodule,args,"timeout",Bert.atom("nil")))) },timeout) - import(`./../../components/${module}`).then((handler)=>{ + import(`./../../components/${module}.js`).then((handler)=>{ handler = handler.default submodule = (submodule == "nil") ? undefined : submodule handler = (!submodule) ? handler : handler[submodule] diff --git a/priv/commonjs_reaxt/webpack_server.js b/priv/commonjs_reaxt/webpack_hot_server.js similarity index 77% rename from priv/commonjs_reaxt/webpack_server.js rename to priv/commonjs_reaxt/webpack_hot_server.js index 3b11749..574c980 100644 --- a/priv/commonjs_reaxt/webpack_server.js +++ b/priv/commonjs_reaxt/webpack_hot_server.js @@ -4,15 +4,6 @@ var webpack = require("webpack"), var multi_config = require(process.cwd()+"/"+process.argv[2]) -//if(process.argv[2] === "hot"){ -// // add hotmodule plugin to client -// client_config.plugins = (client_config.plugins || []).concat([new webpack.HotModuleReplacementPlugin()]) -// // add reloading code to entries -// client_config.add_to_entries(client_config,"webpack/hot/dev-server") -// // remove external which cause conflicts in hot loading -// client_config.externals = {} -//} - var client_stats,client_err function maybe_done() { if(client_err) port.write({event: "client_done", error: JSON.stringify(client_err)})