defmodule QuickBEAM.JS.PackageResolver do @moduledoc false alias QuickBEAM.JS.Exports @node_builtins ~w( assert async_hooks buffer child_process cluster console constants crypto dgram diagnostics_channel dns domain events fs http http2 https inspector module net os path perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls trace_events tty url util v8 vm wasi worker_threads zlib ) @default_extensions [".js", ".mjs", ".cjs", ".json"] @spec relative?(String.t()) :: boolean() def relative?("." <> _), do: true def relative?("/" <> _), do: true def relative?(_), do: false @spec node_builtin?(String.t()) :: boolean() def node_builtin?("node:" <> _), do: true def node_builtin?(name), do: name in @node_builtins @spec split_specifier(String.t()) :: {String.t(), String.t() | nil} def split_specifier("@" <> _ = specifier) do case String.split(specifier, "/", parts: 3) do [scope, name, subpath] -> {"#{scope}/#{name}", "./#{subpath}"} [scope, name] -> {"#{scope}/#{name}", nil} _ -> {specifier, nil} end end def split_specifier(specifier) do case String.split(specifier, "/", parts: 2) do [name, subpath] -> {name, "./#{subpath}"} [name] -> {name, nil} end end @spec find_node_modules(String.t()) :: String.t() | nil def find_node_modules(dir) do dir = Path.expand(dir) candidate = Path.join(dir, "node_modules") cond do File.dir?(candidate) -> candidate dir == "/" -> nil true -> find_node_modules(Path.dirname(dir)) end end @spec try_resolve(String.t(), keyword()) :: {:ok, String.t()} | :error def try_resolve(base, opts \\ []) do extensions = Keyword.get(opts, :extensions, @default_extensions) with :error <- try_exact(base), :error <- try_extensions(base, extensions) do try_index(base, extensions) end end @spec resolve_entry(String.t(), keyword()) :: {:ok, String.t()} | :error def resolve_entry(package_dir, opts \\ []) do subpath = Keyword.get(opts, :subpath, ".") conditions = Keyword.get(opts, :conditions, ["import", "default"]) extensions = Keyword.get(opts, :extensions, @default_extensions) pkg_json_path = Path.join(package_dir, "package.json") case read_package_json(pkg_json_path) do {:ok, pkg} -> resolve_from_pkg(pkg, package_dir, subpath, conditions, extensions) :error -> try_resolve(Path.join(package_dir, "index"), extensions: extensions) end end @spec resolve(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:builtin, String.t()} | :error def resolve(specifier, from_dir, opts \\ []) do cond do node_builtin?(specifier) -> {:builtin, specifier} String.starts_with?(specifier, "#") -> resolve_package_import(specifier, from_dir, opts) relative?(specifier) -> specifier |> Path.expand(from_dir) |> try_resolve(opts) true -> resolve_bare(specifier, from_dir, opts) end end @spec relative_import_path(String.t(), String.t(), String.t()) :: String.t() def relative_import_path(importer, target, project_root) do importer_dir = importer |> Path.relative_to(project_root) |> Path.dirname() target_label = Path.relative_to(target, project_root) target_label |> Path.relative_to(importer_dir) |> ensure_relative_prefix() end @spec nearest_package(String.t()) :: {:ok, String.t(), map()} | :error def nearest_package(dir) do dir = Path.expand(dir) package_json_path = Path.join(dir, "package.json") cond do File.regular?(package_json_path) -> with {:ok, package} <- read_package_json(package_json_path), do: {:ok, dir, package} dir == "/" or Path.basename(dir) == "node_modules" -> :error true -> nearest_package(Path.dirname(dir)) end end @spec package_root(String.t(), String.t()) :: {:ok, String.t()} | :error def package_root(package_name, from_dir) do case find_node_modules(from_dir) do nil -> :error node_modules -> package_dir = Path.join(node_modules, package_name) if File.dir?(package_dir), do: {:ok, package_dir}, else: :error end end defp ensure_relative_prefix("./" <> _ = path), do: path defp ensure_relative_prefix("../" <> _ = path), do: path defp ensure_relative_prefix(path), do: "./" <> path defp resolve_package_import(specifier, from_dir, opts) do conditions = Keyword.get(opts, :conditions, ["import", "default"]) extensions = Keyword.get(opts, :extensions, @default_extensions) with {:ok, package_dir, %{"imports" => imports}} <- nearest_package(from_dir), {:ok, target} <- Exports.resolve(imports, specifier, conditions) do package_dir |> expand_target(target) |> try_resolve(extensions: extensions) else _ -> :error end end defp resolve_bare(specifier, from_dir, opts) do {package_name, subpath} = split_specifier(specifier) with {:ok, package_dir} <- package_root(package_name, from_dir) do opts |> Keyword.put(:subpath, subpath || ".") |> then(&resolve_entry(package_dir, &1)) end end defp resolve_from_pkg(pkg, package_dir, subpath, conditions, extensions) do with :error <- resolve_via_exports(pkg, package_dir, subpath, conditions), :error <- resolve_via_fields(pkg, package_dir, subpath, conditions, extensions) do try_resolve(Path.join(package_dir, "index"), extensions: extensions) end end defp resolve_via_exports(pkg, package_dir, subpath, conditions) do case Exports.parse(pkg) do nil -> :error export_map -> case Exports.resolve(export_map, subpath, conditions) do {:ok, target} -> ensure_file(package_dir, target) :error -> :error end end end defp resolve_via_fields(_pkg, _package_dir, subpath, _conditions, _extensions) when subpath != "." do :error end defp resolve_via_fields(pkg, package_dir, ".", conditions, extensions) do fields = if "browser" in conditions, do: ["browser", "module", "main"], else: ["module", "main"] Enum.find_value(fields, :error, fn field -> case Map.get(pkg, field) do nil -> nil target when is_binary(target) -> resolve_field_target(package_dir, target, extensions) _ -> nil end end) end defp resolve_field_target(package_dir, target, extensions) do full = expand_target(package_dir, target) case try_resolve(full, extensions: extensions) do {:ok, _} = ok -> ok :error -> nil end end defp ensure_file(package_dir, target) do package_dir |> expand_target(target) |> try_resolve(extensions: [""]) end defp expand_target(package_dir, "./" <> rest), do: Path.join(package_dir, rest) defp expand_target(package_dir, target), do: Path.join(package_dir, target) defp try_exact(path) do if File.regular?(path), do: {:ok, path}, else: :error end defp try_extensions(base, extensions) do Enum.find_value(extensions, :error, fn ext -> path = base <> ext if File.regular?(path), do: {:ok, path} end) end defp try_index(base, extensions) do if File.dir?(base), do: try_extensions(Path.join(base, "index"), extensions), else: :error end defp read_package_json(path) do with {:ok, content} <- File.read(path), {:ok, package} <- Jason.decode(content) do {:ok, package} else _ -> :error end end end