Skip to main content

lib/duskmoon_bundler/js/vendor.ex

defmodule DuskmoonBundler.JS.Vendor do
  @moduledoc """
  Pre-bundle vendor (node_modules) dependencies for dev mode.

  Scans source files with `OXC.select/3`, identifies bare specifiers
  (non-relative, non-URL), resolves them through module directories, and
  bundles each into a single ESM file with `OXC.bundle/2`.

  CJS packages (e.g. React) are automatically converted to ESM during
  bundling. `process.env.NODE_ENV` is replaced with `"development"`
  so conditional CJS branches resolve correctly.

  Bundled files are cached on disk in the Mix build root under
  `_build/duskmoon_bundler/vendor/`.
  """

  require Logger

  alias OXC.Bundle

  defp cache_dir do
    Path.join(build_root(), "duskmoon_bundler/vendor")
  end

  defp build_root do
    if Mix.Project.get() do
      Mix.Project.build_path()
      |> Path.dirname()
    else
      Path.expand("_build")
    end
  end

  @doc """
  Scan source files and pre-bundle any bare npm imports.

  Returns a map of `specifier → vendor_path` for import rewriting.

  ## Options

    * `:root` — source directory to scan
    * `:node_modules` — path to node_modules (default: auto-detect)
    * `:resolve_dirs` — additional package directories to resolve from
    * `:vendor_source` — specifiers to serve as source ESM with import rewriting
    * `:force` — rebuild even if cached (default: `false`)
  """
  @spec prebundle(keyword()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
  def prebundle(opts) do
    root = Keyword.fetch!(opts, :root)
    force = Keyword.get(opts, :force, false)
    node_modules = opts[:node_modules] || NPM.Resolution.PackageResolver.find_node_modules(root)
    module_dirs = module_dirs(node_modules, Keyword.get(opts, :resolve_dirs, []))

    plugins = Keyword.get(opts, :plugins, [])
    module_types = Keyword.get(opts, :module_types, %{})
    source_specifiers = Keyword.get(opts, :vendor_source, [])

    with {:ok, specifiers} <- scan_bare_imports(root, plugins),
         :ok <- ensure_cache_dir() do
      specifiers =
        specifiers
        |> Enum.map(&DuskmoonBundler.PluginRunner.prebundle_alias(plugins, &1))
        |> Enum.uniq()

      prebundle_vendors(specifiers, module_dirs, force, plugins, module_types, source_specifiers)
    end
  end

  @doc """
  Bundle a single vendor specifier on demand.

  Used by the dev server when a `/@vendor/` request arrives for a
  specifier that wasn't caught by `prebundle/1` (e.g. transitive
  dependency, or newly added import).
  """
  @spec bundle_on_demand(String.t(), String.t() | nil, keyword()) ::
          {:ok, String.t()} | {:error, term()}
  def bundle_on_demand(specifier, node_modules, opts \\ []) do
    ensure_cache_dir()

    {plugins, resolve_dirs, module_types, source_specifiers, browser_token} =
      normalize_on_demand_opts(opts)

    module_dirs = module_dirs(node_modules, resolve_dirs)
    specifier = DuskmoonBundler.PluginRunner.prebundle_alias(plugins, specifier)

    result =
      if source_vendor?(specifier, source_specifiers) do
        source_vendor(
          specifier,
          module_dirs,
          false,
          plugins,
          module_types,
          source_specifiers,
          browser_token
        )
      else
        bundle_vendor(
          specifier,
          module_dirs,
          false,
          plugins,
          module_types,
          source_specifiers,
          browser_token
        )
      end

    case result do
      {:ok, path} -> File.read(path)
      {:error, _} = error -> error
    end
  end

  @doc """
  Get the URL path for a vendor module.
  """
  @spec vendor_url(String.t()) :: String.t()
  def vendor_url(specifier), do: "/@vendor/#{encode_specifier(specifier)}.js"

  @doc "Get the URL path for a vendor module with a cache-busting browser hash."
  @spec vendor_url(String.t(), keyword()) :: String.t()
  def vendor_url(specifier, opts) do
    specifier
    |> vendor_url()
    |> DuskmoonBundler.URL.append_query("v=#{browser_hash_for_url(opts)}")
    |> append_browser_token(Keyword.get(opts, :browser_token))
  end

  @doc "Return whether a request browser hash matches the current optimized dependency state."
  @spec current_browser_hash?(String.t() | nil, keyword()) :: boolean()
  def current_browser_hash?(nil, _opts), do: true
  def current_browser_hash?(hash, opts), do: hash == browser_hash_for_url(opts)

  @doc "Return the current browser hash for optimized dependency requests."
  @spec browser_hash(keyword()) :: String.t()
  def browser_hash(opts) do
    {plugins, resolve_dirs, module_types, source_specifiers, _browser_token} =
      normalize_on_demand_opts(opts)

    node_modules = Keyword.get(opts, :node_modules)
    module_dirs = module_dirs(node_modules, resolve_dirs)

    browser_hash(module_dirs, plugins, module_types, source_specifiers)
  end

  defp browser_hash_for_url(opts) do
    case Keyword.get(opts, :browser_hash) do
      hash when is_binary(hash) and hash != "" -> hash
      _ -> browser_hash(opts)
    end
  end

  defp browser_hash(module_dirs, plugins, module_types, source_specifiers) do
    :crypto.hash(
      :sha256,
      :erlang.term_to_binary(
        browser_signature(module_dirs, plugins, module_types, source_specifiers)
      )
    )
    |> Base.encode16(case: :lower)
    |> binary_part(0, 8)
  end

  @doc """
  Read a pre-bundled vendor file by specifier.
  """
  @spec read(String.t()) :: {:ok, String.t()} | {:error, :not_found}
  def read(specifier), do: read_cached(specifier)

  @doc "Read a pre-bundled vendor file when its cache signature matches the current options."
  @spec read(String.t(), keyword()) :: {:ok, String.t()} | {:error, :not_found}
  def read("chunks/" <> _ = specifier, _opts), do: read_cached(specifier)

  def read(specifier, opts) do
    {plugins, resolve_dirs, module_types, source_specifiers, browser_token} =
      normalize_on_demand_opts(opts)

    node_modules = Keyword.get(opts, :node_modules)
    module_dirs = module_dirs(node_modules, resolve_dirs)
    specifier = DuskmoonBundler.PluginRunner.prebundle_alias(plugins, specifier)

    if cache_fresh?(
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
      read_cached(specifier)
    else
      {:error, :not_found}
    end
  end

  # ── Scanning ──────────────────────────────────────────────────────

  defp scan_bare_imports(root, plugins) do
    exts = DuskmoonBundler.JS.Extensions.scannable(plugins)
    source_files = collect_source_files(root, exts)

    specifiers =
      Enum.flat_map(source_files, fn file ->
        with {:ok, source} <- File.read(file),
             {:ok, imports} <- extract_imports(source, file, plugins) do
          Enum.filter(imports, &NPM.Resolution.PackageResolver.bare?/1)
        else
          _ -> []
        end
      end)

    {:ok, specifiers}
  end

  defp collect_source_files(dir, exts) do
    case File.ls(dir) do
      {:ok, entries} ->
        Enum.flat_map(entries, &collect_source_entry(dir, &1, exts))

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

  defp collect_source_entry(dir, entry, exts) do
    path = Path.join(dir, entry)

    cond do
      File.dir?(path) and entry in DuskmoonBundler.Paths.ignored_dirs() -> []
      File.dir?(path) -> collect_source_files(path, exts)
      Path.extname(entry) in exts -> [path]
      true -> []
    end
  end

  defp extract_imports(source, path, plugins) do
    case DuskmoonBundler.PluginRunner.extract_imports(plugins, path, source, []) do
      {:ok, %{imports: imports}} -> {:ok, Enum.map(imports, fn {_type, spec} -> spec end)}
      nil -> OXC.select(source, Path.basename(path), :import_specifiers)
      {:error, _} = error -> error
    end
  end

  # ── Bundling ──────────────────────────────────────────────────────

  defp prebundle_vendors([], _module_dirs, _force, _plugins, _module_types, _source_specifiers),
    do: {:ok, %{}}

  defp prebundle_vendors(specifiers, module_dirs, force, plugins, module_types, source_specifiers) do
    vendor_map = Map.new(specifiers, &{&1, cache_path(&1)})

    if not force and
         Enum.all?(
           specifiers,
           &cache_fresh?(&1, module_dirs, plugins, module_types, source_specifiers, nil)
         ) do
      {:ok, vendor_map}
    else
      {source_specs, bundle_specs} =
        Enum.split_with(specifiers, &source_vendor?(&1, source_specifiers))

      with {:ok, source_map} <-
             source_vendors(
               source_specs,
               module_dirs,
               force,
               plugins,
               module_types,
               source_specifiers
             ),
           {:ok, bundle_map} <-
             safe_bundle_vendors(
               bundle_specs,
               module_dirs,
               plugins,
               module_types,
               source_specifiers,
               vendor_map
             ) do
        {:ok, Map.merge(source_map, bundle_map)}
      end
    end
  end

  defp source_vendors([], _module_dirs, _force, _plugins, _module_types, _source_specifiers),
    do: {:ok, %{}}

  defp source_vendors(specifiers, module_dirs, force, plugins, module_types, source_specifiers) do
    specifiers
    |> Enum.reduce(%{}, fn specifier, acc ->
      case source_vendor(
             specifier,
             module_dirs,
             force,
             plugins,
             module_types,
             source_specifiers,
             nil
           ) do
        {:ok, path} -> Map.put(acc, specifier, path)
        {:error, _} -> acc
      end
    end)
    |> then(&{:ok, &1})
  end

  defp safe_bundle_vendors(
         [],
         _module_dirs,
         _plugins,
         _module_types,
         _source_specifiers,
         _vendor_map
       ),
       do: {:ok, %{}}

  defp safe_bundle_vendors(
         specifiers,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         vendor_map
       ) do
    bundle_vendors(specifiers, module_dirs, plugins, module_types, source_specifiers, vendor_map)
  rescue
    exception ->
      Logger.debug(
        "[DuskmoonBundler] Vendor prebundle fell back to per-package bundling: #{Exception.message(exception)}"
      )

      fallback_bundle_vendors(specifiers, module_dirs, plugins, module_types, source_specifiers)
  end

  defp bundle_vendors(
         specifiers,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         vendor_map
       ) do
    entries =
      Enum.flat_map(specifiers, fn specifier ->
        case bundle_entry_for(specifier, module_dirs, plugins) do
          {:ok, specifier, entry} -> [{specifier, entry}]
          {:error, _} -> []
        end
      end)

    case entries do
      [] ->
        {:ok, %{}}

      entries ->
        run_vendor_bundle(
          entries,
          specifiers,
          module_dirs,
          plugins,
          module_types,
          source_specifiers,
          vendor_map
        )
    end
  end

  defp run_vendor_bundle(
         entries,
         specifiers,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         vendor_map
       ) do
    bundled_specifiers = Enum.map(entries, fn {specifier, _entry} -> specifier end)
    bundle_entries = Enum.map(entries, fn {_specifier, entry} -> entry end)

    bundle =
      Bundle.new()
      |> Bundle.entries(bundle_entries)
      |> Bundle.cwd(project_root(module_dirs))
      |> Bundle.outdir(cache_dir())
      |> Bundle.format(:esm)
      |> Bundle.resolve(
        conditions: DuskmoonBundler.JS.Resolution.browser_conditions(),
        modules: module_dirs
      )
      |> Bundle.transform(
        define: %{"process.env.NODE_ENV" => ~s("development")},
        module_types: module_types
      )
      |> Bundle.output(
        entry_file_names: "[name].js",
        chunk_file_names: "chunks/[name]-[hash].js",
        exports: :named,
        preserve_entry_signatures: :strict
      )

    case Bundle.run(bundle) do
      {:ok, _result} ->
        write_vendor_cache_metadata(
          bundled_specifiers,
          module_dirs,
          plugins,
          module_types,
          source_specifiers
        )

        {:ok, Map.take(vendor_map, bundled_specifiers)}

      {:error, errors} ->
        Logger.debug("[DuskmoonBundler] Vendor multi-entry prebundle failed: #{inspect(errors)}")
        fallback_bundle_vendors(specifiers, module_dirs, plugins, module_types, source_specifiers)
    end
  end

  defp write_vendor_cache_metadata(
         specifiers,
         module_dirs,
         plugins,
         module_types,
         source_specifiers
       ) do
    Enum.each(specifiers, fn specifier ->
      if File.regular?(cache_path(specifier)) do
        File.write!(
          cache_meta_path(specifier),
          cache_signature(specifier, module_dirs, plugins, module_types, source_specifiers, nil)
        )
      end
    end)
  end

  defp fallback_bundle_vendors(specifiers, module_dirs, plugins, module_types, source_specifiers) do
    specifiers
    |> Enum.reduce(%{}, fn specifier, acc ->
      case bundle_vendor(
             specifier,
             module_dirs,
             false,
             plugins,
             module_types,
             source_specifiers,
             nil
           ) do
        {:ok, path} -> Map.put(acc, specifier, path)
        {:error, _} -> acc
      end
    end)
    |> then(&{:ok, &1})
  end

  defp bundle_entry_for(specifier, module_dirs, plugins) do
    case DuskmoonBundler.PluginRunner.prebundle_entry(plugins, specifier) do
      {:source, filename, source} ->
        {:ok, specifier, %{name: encode_specifier(specifier), import: filename, source: source}}

      {:proxy, filename, _opts} = entry ->
        {:ok, specifier,
         %{
           name: encode_specifier(specifier),
           import: filename,
           source: DuskmoonBundler.JS.PrebundleEntry.source(entry)
         }}

      nil ->
        case resolve_package_entry(specifier, module_dirs) do
          {:ok, entry_path} ->
            {:ok, specifier, %{name: encode_specifier(specifier), import: entry_path}}

          :error ->
            {:error, {:not_found, specifier}}
        end
    end
  end

  defp bundle_vendor(
         specifier,
         module_dirs,
         force,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    path = cache_path(specifier)

    if not force and File.regular?(path) and
         cache_fresh?(
           specifier,
           module_dirs,
           plugins,
           module_types,
           source_specifiers,
           browser_token
         ) do
      {:ok, path}
    else
      do_bundle_vendor(
        specifier,
        module_dirs,
        path,
        plugins,
        module_types,
        source_specifiers,
        browser_token
      )
    end
  end

  defp do_bundle_vendor(
         specifier,
         module_dirs,
         output_path,
         plugins,
         module_types,
         source_specifiers,
         _browser_token
       ) do
    case bundle_entry_for(specifier, module_dirs, plugins) do
      {:ok, ^specifier, entry} ->
        bundle =
          Bundle.new()
          |> Bundle.entry(entry)
          |> Bundle.cwd(project_root(module_dirs))
          |> Bundle.outdir(cache_dir())
          |> Bundle.format(:esm)
          |> Bundle.resolve(
            conditions: DuskmoonBundler.JS.Resolution.browser_conditions(),
            modules: module_dirs
          )
          |> Bundle.transform(
            define: %{"process.env.NODE_ENV" => ~s("development")},
            module_types: module_types
          )
          |> Bundle.output(
            entry_file_names: "[name].js",
            chunk_file_names: "chunks/[name]-[hash].js",
            exports: :named,
            preserve_entry_signatures: :strict
          )

        case Bundle.run(bundle) do
          {:ok, _result} ->
            write_vendor_cache_metadata(
              [specifier],
              module_dirs,
              plugins,
              module_types,
              source_specifiers
            )

            if File.regular?(output_path) do
              {:ok, output_path}
            else
              {:error, {:not_found, specifier}}
            end

          {:error, _} = error ->
            error
        end

      {:error, _} = error ->
        error
    end
  end

  defp source_vendor(
         specifier,
         module_dirs,
         force,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    path = cache_path(specifier)

    if not force and File.regular?(path) and
         cache_fresh?(
           specifier,
           module_dirs,
           plugins,
           module_types,
           source_specifiers,
           browser_token
         ) do
      {:ok, path}
    else
      do_source_vendor(
        specifier,
        module_dirs,
        path,
        plugins,
        module_types,
        source_specifiers,
        browser_token
      )
    end
  end

  defp do_source_vendor(
         specifier,
         module_dirs,
         output_path,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    with {:ok, entry_path, _project_root} <- prebundle_entry(specifier, module_dirs, plugins),
         {:ok, source} <- File.read(entry_path),
         {:ok, code} <-
           DuskmoonBundler.JS.Transforms.Imports.rewrite(
             source,
             Path.basename(entry_path),
             &rewrite_source_import(
               &1,
               module_dirs,
               plugins,
               module_types,
               source_specifiers,
               browser_token
             )
           ) do
      write_cache_files!(
        output_path,
        code,
        specifier,
        module_dirs,
        plugins,
        module_types,
        source_specifiers,
        browser_token
      )

      {:ok, output_path}
    else
      :error -> {:error, {:not_found, specifier}}
      {:error, _} = error -> error
    end
  end

  defp prebundle_entry(specifier, module_dirs, plugins) do
    case DuskmoonBundler.PluginRunner.prebundle_entry(plugins, specifier) do
      {:source, filename, source} ->
        synthetic_prebundle_entry(specifier, filename, source, module_dirs)

      {:proxy, filename, _opts} = entry ->
        synthetic_prebundle_entry(
          specifier,
          filename,
          DuskmoonBundler.JS.PrebundleEntry.source(entry),
          module_dirs
        )

      nil ->
        package_prebundle_entry(specifier, module_dirs)
    end
  end

  defp synthetic_prebundle_entry(specifier, filename, source, _module_dirs) do
    dir = Path.expand(Path.join([cache_dir(), "entries", encode_specifier(specifier)]))
    path = Path.join(dir, filename)
    File.mkdir_p!(dir)
    File.write!(path, source)
    {:ok, path, dir}
  end

  defp package_prebundle_entry(specifier, module_dirs) do
    case resolve_package_entry(specifier, module_dirs) do
      {:ok, entry_path} -> {:ok, entry_path, package_project_root(entry_path, module_dirs)}
      :error -> :error
    end
  end

  defp package_project_root(entry_path, module_dirs) do
    entry_path
    |> Path.dirname()
    |> NPM.Resolution.PackageResolver.nearest_package()
    |> case do
      {:ok, package_dir, _package} -> Path.dirname(package_dir)
      :error -> project_root(module_dirs)
    end
  end

  # ── Helpers ───────────────────────────────────────────────────────

  defp resolve_package_entry(specifier, module_dirs) do
    Enum.find_value(module_dirs, :error, fn module_dir ->
      result =
        with :error <- resolve_from_node_modules(specifier, module_dir) do
          resolve_from_module_dir(specifier, module_dir)
        else
          {:ok, _path} = ok -> ok
        end

      case result do
        {:ok, _path} = ok -> ok
        :error -> nil
      end
    end)
  end

  defp resolve_from_node_modules(specifier, module_dir) do
    NPM.Resolution.PackageResolver.resolve(specifier, module_dir,
      extensions: DuskmoonBundler.JS.Extensions.resolvable(),
      conditions: DuskmoonBundler.JS.Resolution.browser_conditions()
    )
  end

  defp resolve_from_module_dir(specifier, module_dir) do
    {package_name, subpath} = split_specifier(specifier)
    package_dir = Path.join(module_dir, package_name)

    if File.dir?(package_dir) do
      extensions = DuskmoonBundler.JS.Extensions.resolvable()

      case NPM.Resolution.PackageResolver.resolve_entry(package_dir,
             subpath: subpath || ".",
             extensions: extensions,
             conditions: DuskmoonBundler.JS.Resolution.browser_conditions()
           ) do
        {:ok, _path} = ok -> ok
        :error -> resolve_module_dir_subpath(package_dir, subpath || ".", extensions)
      end
    else
      :error
    end
  end

  defp resolve_module_dir_subpath(package_dir, subpath, extensions) do
    path =
      subpath
      |> String.trim_leading("./")
      |> then(&Path.join(package_dir, &1))

    resolve_file_or_directory(path, extensions)
  end

  defp resolve_file_or_directory(path, extensions) do
    cond do
      File.regular?(path) ->
        {:ok, path}

      match = resolve_with_extensions(path, extensions) ->
        {:ok, match}

      File.dir?(path) ->
        resolve_with_extensions(Path.join(path, "index"), extensions)
        |> case do
          nil -> :error
          match -> {:ok, match}
        end

      true ->
        :error
    end
  end

  defp resolve_with_extensions(path, extensions) do
    Enum.find(extensions, &File.regular?(path <> &1))
    |> case do
      nil -> nil
      extension -> path <> extension
    end
  end

  defp split_specifier("@" <> rest = specifier) do
    case String.split(rest, "/", parts: 3) do
      [_scope, _name] -> {specifier, nil}
      [scope, name, subpath] -> {"@#{scope}/#{name}", "./#{subpath}"}
    end
  end

  defp split_specifier(specifier) do
    case String.split(specifier, "/", parts: 2) do
      [name] -> {name, nil}
      [name, subpath] -> {name, "./#{subpath}"}
    end
  end

  defp normalize_on_demand_opts(opts) do
    if Keyword.keyword?(opts) do
      {Keyword.get(opts, :plugins, []), Keyword.get(opts, :resolve_dirs, []),
       Keyword.get(opts, :module_types, %{}), Keyword.get(opts, :vendor_source, []),
       Keyword.get(opts, :browser_token)}
    else
      {opts, [], %{}, [], nil}
    end
  end

  defp source_vendor?(specifier, source_specifiers), do: specifier in List.wrap(source_specifiers)

  defp rewrite_source_import(
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    cond do
      NPM.Resolution.PackageResolver.node_builtin?(specifier) ->
        :keep

      NPM.Resolution.PackageResolver.bare?(specifier) ->
        specifier = DuskmoonBundler.PluginRunner.prebundle_alias(plugins, specifier)

        {:rewrite,
         vendor_url(
           specifier,
           module_dirs,
           plugins,
           module_types,
           source_specifiers,
           browser_token
         )}

      true ->
        :keep
    end
  end

  defp vendor_url(specifier, module_dirs, plugins, module_types, source_specifiers, browser_token) do
    specifier
    |> vendor_url()
    |> DuskmoonBundler.URL.append_query(
      "v=#{browser_hash(module_dirs, plugins, module_types, source_specifiers)}"
    )
    |> append_browser_token(browser_token)
  end

  defp append_browser_token(url, nil), do: url
  defp append_browser_token(url, ""), do: url
  defp append_browser_token(url, token), do: DuskmoonBundler.URL.append_query(url, "t=#{token}")

  defp module_dirs(node_modules, resolve_dirs) do
    node_modules
    |> node_modules_dirs_for()
    |> Kernel.++(List.wrap(resolve_dirs))
    |> Enum.reject(&is_nil/1)
    |> Enum.map(&Path.expand/1)
    |> Enum.uniq()
  end

  defp node_modules_dirs_for(nil), do: []

  defp node_modules_dirs_for(node_modules) do
    node_modules
    |> Path.dirname()
    |> ancestors()
    |> Enum.map(&Path.join(&1, "node_modules"))
    |> Enum.filter(&File.dir?/1)
  end

  defp ancestors(path) do
    parent = Path.dirname(path)

    if parent == path do
      [path]
    else
      [path | ancestors(parent)]
    end
  end

  defp project_root([module_dir | _]), do: Path.dirname(module_dir)
  defp project_root([]), do: File.cwd!()

  defp ensure_cache_dir do
    File.mkdir_p!(cache_dir())
    :ok
  end

  defp read_cached("chunks/" <> _ = specifier) do
    Path.join(cache_dir(), specifier <> ".js")
    |> File.read()
    |> case do
      {:ok, _} = ok -> ok
      {:error, _} -> {:error, :not_found}
    end
  end

  defp read_cached(specifier) do
    specifier
    |> cache_path()
    |> File.read()
    |> case do
      {:ok, _} = ok -> ok
      {:error, _} -> {:error, :not_found}
    end
  end

  defp cache_fresh?(
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    File.regular?(cache_path(specifier)) and
      File.read(cache_meta_path(specifier)) ==
        {:ok,
         cache_signature(
           specifier,
           module_dirs,
           plugins,
           module_types,
           source_specifiers,
           browser_token
         )}
  end

  defp write_cache_files!(
         output_path,
         code,
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    meta_path = cache_meta_path(specifier)
    nonce = cache_nonce()
    tmp_output = "#{output_path}.#{nonce}.tmp"
    tmp_meta = "#{meta_path}.#{nonce}.tmp"

    try do
      File.write!(tmp_output, code)

      File.write!(
        tmp_meta,
        cache_signature(
          specifier,
          module_dirs,
          plugins,
          module_types,
          source_specifiers,
          browser_token
        )
      )

      File.rename!(tmp_output, output_path)
      File.rename!(tmp_meta, meta_path)
    after
      File.rm(tmp_output)
      File.rm(tmp_meta)
    end
  end

  defp cache_signature(
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    :crypto.hash(
      :sha256,
      :erlang.term_to_binary(
        signature_terms(
          specifier,
          module_dirs,
          plugins,
          module_types,
          source_specifiers,
          browser_token
        )
      )
    )
    |> Base.encode16(case: :lower)
  end

  defp cache_nonce do
    random =
      6
      |> :crypto.strong_rand_bytes()
      |> Base.url_encode64(padding: false)

    [System.os_time(:nanosecond), System.unique_integer([:positive]), random]
    |> Enum.join("-")
  end

  defp signature_terms(
         specifier,
         module_dirs,
         plugins,
         module_types,
         source_specifiers,
         browser_token
       ) do
    browser_signature(module_dirs, plugins, module_types, source_specifiers)
    |> Map.put(:specifier, specifier)
    |> Map.put(:plugins, Enum.map(plugins, &plugin_signature(&1, specifier)))
    |> Map.put(:package, package_signature(specifier, module_dirs))
    |> Map.put(:source_vendor, source_vendor?(specifier, source_specifiers))
    |> Map.put(:browser_token, source_browser_token(specifier, source_specifiers, browser_token))
  end

  defp source_browser_token(specifier, source_specifiers, browser_token) do
    if source_vendor?(specifier, source_specifiers), do: browser_token, else: nil
  end

  defp browser_signature(module_dirs, plugins, module_types, source_specifiers) do
    %{
      lockfiles: lockfile_signature(module_dirs),
      module_dirs: module_dirs,
      module_types: module_types,
      plugins: Enum.map(plugins, &base_plugin_signature/1),
      vendor_source: List.wrap(source_specifiers)
    }
  end

  defp base_plugin_signature({module, opts}), do: {module, opts}
  defp base_plugin_signature(module), do: module

  defp plugin_signature({module, opts}, specifier),
    do: {module, opts, plugin_entry_signature(module, specifier)}

  defp plugin_signature(module, specifier),
    do: {module, plugin_entry_signature(module, specifier)}

  defp plugin_entry_signature(module, specifier) do
    if function_exported?(module, :prebundle_entry, 1) do
      module.prebundle_entry(specifier)
    end
  end

  defp package_signature(specifier, module_dirs) do
    case resolve_package_entry(specifier, module_dirs) do
      {:ok, entry_path} ->
        {entry_path, file_signature(entry_path), package_json_signature(entry_path)}

      :error ->
        :error
    end
  end

  defp package_json_signature(entry_path) do
    entry_path
    |> Path.dirname()
    |> NPM.Resolution.PackageResolver.nearest_package()
    |> case do
      {:ok, package_dir, _package} -> file_signature(Path.join(package_dir, "package.json"))
      :error -> nil
    end
  end

  @lockfiles ~w(package-lock.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb)

  defp lockfile_signature(module_dirs) do
    module_dirs
    |> lockfile_roots()
    |> Enum.flat_map(&lockfiles_in/1)
  end

  defp lockfile_roots([]), do: [File.cwd!()]

  defp lockfile_roots(module_dirs) do
    module_dirs
    |> Enum.map(&Path.dirname/1)
    |> Kernel.++([File.cwd!()])
    |> Enum.uniq()
  end

  defp lockfiles_in(root) do
    @lockfiles
    |> Enum.map(&Path.join(root, &1))
    |> Enum.filter(&File.regular?/1)
    |> Enum.map(&{&1, file_signature(&1)})
  end

  defp file_signature(path) do
    case File.read(path) do
      {:ok, contents} -> :crypto.hash(:sha256, contents) |> Base.encode16(case: :lower)
      {:error, _} -> nil
    end
  end

  defp cache_path(specifier) do
    Path.join(cache_dir(), encode_specifier(specifier) <> ".js")
  end

  defp cache_meta_path(specifier), do: cache_path(specifier) <> ".meta"

  @doc "Encode a specifier for use in URLs (escaping @ and /)."
  def encode_specifier(specifier) do
    specifier
    |> String.replace("@", "__at__")
    |> String.replace("/", "__slash__")
  end

  @doc "Decode a URL-safe specifier back to its original form."
  def decode_specifier(encoded) do
    encoded
    |> String.replace("__slash__", "/")
    |> String.replace("__at__", "@")
  end
end