Skip to main content

lib/mix/tasks/compile.elixir_ts_rpc.ex

defmodule Mix.Tasks.Compile.ElixirTsRpc do
  @moduledoc """
  Mix compiler that regenerates the TypeScript client after Elixir recompiles.

  Configure in your project's `config/config.exs`:

      config :elixir_ts_rpc,
        router: MyApp.Router,
        out: Path.expand("../client/src/rpc.gen.ts", __DIR__)

  And append to the compilers list in `mix.exs`:

      compilers: Mix.compilers() ++ [:elixir_ts_rpc]

  ## Build artifacts

  The generated `.ts` file is tracked as a build artifact via `manifests/0`.
  Running `mix clean` removes it along with the standard BEAM artifacts.
  """

  use Mix.Task.Compiler

  @impl true
  def run(_args) do
    with {:ok, router} <- fetch_config(:router),
         {:ok, out} <- fetch_config(:out) do
      regenerate(router, out)
    else
      {:error, :not_configured} -> {:noop, []}
    end
  end

  @impl true
  def manifests do
    case Application.get_env(:elixir_ts_rpc, :out) do
      nil -> []
      out -> [out]
    end
  end

  @impl true
  def clean do
    Enum.each(manifests(), fn path ->
      if File.exists?(path), do: File.rm!(path)
    end)
  end

  defp fetch_config(key) do
    case Application.get_env(:elixir_ts_rpc, key) do
      nil -> {:error, :not_configured}
      value -> {:ok, value}
    end
  end

  defp regenerate(router, out) do
    # Rely on the normal Elixir compiler to produce fresh BEAM files.
    # Code.ensure_loaded/1 is sufficient — we do NOT manually purge or
    # load_file here, which would race the parallel compiler and could purge
    # a module currently held by another process.
    case Code.ensure_loaded(router) do
      {:module, _} ->
        {write_if_changed(router, out), []}

      {:error, _} ->
        {:noop, []}
    end
  end

  defp write_if_changed(router, out) do
    new_content = RpcElixir.Codegen.generate(router, out: out)
    existing = if File.exists?(out), do: File.read!(out), else: :none

    if existing == new_content do
      :noop
    else
      File.mkdir_p!(Path.dirname(out))
      File.write!(out, new_content)
      Mix.shell().info("[elixir_ts_rpc] regenerated #{out}")
      :ok
    end
  end
end