defmodule Mix.Tasks.Mf.Serve do use Mix.Task @shortdoc "Serve a Plug-compatible module from a directory (e.g. mix mf.serve examples/)" @moduledoc """ Usage: mix mf.serve [PATH] [--module MyApp] [--port 4000] [--ip 127.0.0.1] This task will require all Elixir source files under PATH (defaults to ".") and attempt to find a module to serve with Bandit. If `--module` is provided, that module will be used. Otherwise the task will pick a sensible module from the loaded files (preferring `ExampleApp` if present, otherwise the first module discovered). The chosen module must implement the Plug interface (i.e. export `call/2`). """ @impl true def run(argv) do # Ensure the project application is started so dependencies are available. # We still run inside Mix, but we want runtime apps started. Mix.Task.run("app.start") {opts, rest, _} = OptionParser.parse(argv, switches: [module: :string, port: :integer, ip: :string], aliases: [m: :module, p: :port] ) path = case rest do [p | _] -> p [] -> "." end |> Path.expand() unless File.dir?(path) do Mix.raise("Path not found or not a directory: #{path}") end # Collect .ex and .exs files under the target path ex_files = Path.wildcard(Path.join(path, "**/*.{ex,exs}")) |> Enum.sort() modules = ex_files |> Enum.flat_map(fn file -> try do # Code.require_file returns [{Module, binary}|...] require_result = Code.require_file(file) Enum.map(require_result, fn {m, _bin} -> m m when is_atom(m) -> m _ -> nil end) |> Enum.filter(&is_atom/1) rescue _ -> [] end end) |> Enum.uniq() target_mod = case opts[:module] do nil -> choose_default_module(modules) mod_name when is_binary(mod_name) -> string_to_module(mod_name) end if target_mod == nil do Mix.shell().error("No module to serve was detected in #{path}.") Mix.shell().info("Scanned files:\n #{Enum.join(ex_files, "\n ")}") Mix.raise( "Provide --module MODULE or ensure a Plug-compatible module is defined in the path." ) end unless function_exported?(target_mod, :call, 2) do Mix.raise( "Module #{inspect(target_mod)} does not implement call/2. Please provide a Plug-compatible module." ) end port = opts[:port] || 4000 ip = parse_ip(opts[:ip]) || {0, 0, 0, 0} # Start Bandit and its runtime dependencies {:ok, _} = Application.ensure_all_started(:bandit) children = [ {Bandit, plug: target_mod, port: port, ip: ip} ] {:ok, _sup} = Supervisor.start_link(children, strategy: :one_for_one) Mix.shell().info("") Mix.shell().info("Serving #{inspect(target_mod)} at http://#{ip_to_string(ip)}:#{port}") Mix.shell().info("Press Ctrl+C twice to stop") # Keep the task running while the server runs Process.sleep(:infinity) end defp choose_default_module(modules) do cond do Enum.member?(modules, ExampleApp) -> ExampleApp modules != [] -> List.first(modules) true -> nil end end defp string_to_module(name) do name |> String.split(".") |> Enum.map(&String.to_atom/1) |> Module.concat() end defp parse_ip(nil), do: nil defp parse_ip(<<_::binary>> = s) do case :inet.parse_address(String.to_charlist(s)) do {:ok, ip} -> ip _ -> nil end end defp ip_to_string({a, b, c, d}) when is_integer(a) do Enum.join([a, b, c, d], ".") end defp ip_to_string(ip), do: to_string(:inet.ntoa(ip)) end