Skip to main content

lib/mix/tasks/rpc.gen.ts.watch.ex

defmodule Mix.Tasks.Rpc.Gen.Ts.Watch do
  @moduledoc """
  Watches Elixir sources and regenerates the TypeScript client on every change.

  Runs `rpc.gen.ts` once on startup, then re-runs it whenever a `.ex` file
  under the watched directories changes.

  Each regeneration spawns a fresh `mix rpc.gen.ts` process. This is
  deliberate: an in-process `Mix.Task.rerun("compile")` does not pick up source
  edits — the Elixir compiler caches its manifest for the lifetime of the BEAM,
  so it reports `:noop` and the generated client goes stale. A separate process
  re-reads the manifest from disk and recompiles correctly.

  ## Usage

      mix rpc.gen.ts.watch --router MyApp.Router --out path/to/rpc.gen.ts

  Accepts every option `rpc.gen.ts` accepts, plus:

    * `--dir` - a directory to watch (repeatable; defaults to `lib`)

  Requires the optional `:file_system` dependency:

      {:file_system, "~> 1.0", only: :dev}

  """

  use Mix.Task

  @shortdoc "Watch Elixir sources and regenerate the TypeScript client on change"

  @debounce_ms 200

  @impl Mix.Task
  def run(["--help"]), do: Mix.Task.run("help", ["rpc.gen.ts.watch"])
  def run(["-h"]), do: Mix.Task.run("help", ["rpc.gen.ts.watch"])

  def run(args) do
    ensure_file_system!()

    {gen_args, watch_dirs} = split_args(args)

    regenerate(gen_args)

    {:ok, watcher} = FileSystem.start_link(dirs: watch_dirs)
    FileSystem.subscribe(watcher)

    Mix.shell().info("Watching #{Enum.join(watch_dirs, ", ")} for *.ex changes...")
    watch_loop(gen_args)
  end

  defp watch_loop(gen_args) do
    receive do
      {:file_event, _watcher, {path, _events}} ->
        if String.ends_with?(path, ".ex") do
          drain_events()
          regenerate(gen_args)
        end

        watch_loop(gen_args)

      {:file_event, _watcher, :stop} ->
        :ok
    end
  end

  # Wait for a quiet window before regenerating — editors emit several events
  # per save, and a single keystroke can touch many files.
  defp drain_events do
    receive do
      {:file_event, _watcher, _payload} -> drain_events()
    after
      @debounce_ms -> :ok
    end
  end

  defp regenerate(gen_args) do
    {_output, status} =
      System.cmd("mix", ["rpc.gen.ts" | gen_args],
        into: IO.stream(:stdio, :line),
        stderr_to_stdout: true
      )

    if status != 0 do
      Mix.shell().error("[rpc.gen.ts.watch] regeneration failed (exit #{status})")
    end
  end

  defp split_args(args) do
    {opts, _rest, _invalid} =
      OptionParser.parse(args,
        switches: [router: :string, out: :string, client_import: :string, dir: :keep]
      )

    watch_dirs =
      case Keyword.get_values(opts, :dir) do
        [] -> [Path.expand("lib")]
        dirs -> Enum.map(dirs, &Path.expand/1)
      end

    gen_args = opts |> Keyword.delete(:dir) |> to_argv()

    {gen_args, watch_dirs}
  end

  defp to_argv(opts) do
    Enum.flat_map(opts, fn {key, value} ->
      flag = "--" <> (key |> Atom.to_string() |> String.replace("_", "-"))
      [flag, to_string(value)]
    end)
  end

  defp ensure_file_system! do
    unless Code.ensure_loaded?(FileSystem) do
      Mix.raise("""
      mix rpc.gen.ts.watch requires the :file_system dependency.

      Add it to your deps and run `mix deps.get`:

          {:file_system, "~> 1.0", only: :dev}
      """)
    end
  end
end