defmodule NPM.Install.Linker do
@moduledoc """
Creates `node_modules` from the global cache.
Supports multiple linking strategies:
- `:symlink` (default) — symlinks from `node_modules/pkg` to cache
- `:copy` — full file copy
Uses a hoisted layout where packages are placed as high in the tree
as possible, only nesting when version conflicts occur.
"""
@cache_concurrency 8
@link_concurrency 16
@type strategy :: :symlink | :copy
@type resolved :: %{String.t() => NPM.Lockfile.entry()}
@type nested_info :: %{String.t() => term()}
@doc """
Link all resolved packages into `node_modules`.
First populates the global cache, then creates the `node_modules` tree.
"""
@spec link(resolved(), String.t(), strategy(), MapSet.t() | nil) :: :ok | {:error, term()}
def link(
lockfile,
node_modules_dir \\ "node_modules",
strategy \\ default_strategy(),
skipped \\ nil
) do
with {:ok, skipped} <- populate_cache(lockfile, skipped) do
create_node_modules(lockfile, node_modules_dir, strategy, skipped)
end
end
@doc """
Link local workspace and directory `file:` packages into `node_modules`.
"""
@spec link_local_packages(%{String.t() => String.t()}, String.t()) :: :ok | {:error, term()}
def link_local_packages(local_links, node_modules_dir \\ "node_modules")
def link_local_packages(local_links, _node_modules_dir) when map_size(local_links) == 0, do: :ok
def link_local_packages(local_links, node_modules_dir) do
File.mkdir_p!(node_modules_dir)
with :ok <-
local_links
|> Enum.sort_by(&elem(&1, 0))
|> run_concurrently(fn {name, source} ->
source = Path.expand(source)
target = Path.join(node_modules_dir, name)
link_local_package(source, target)
end) do
link_bins(node_modules_dir, Enum.map(local_links, fn {name, _path} -> {name, "local"} end))
end
end
@doc """
Link nested packages into parent package `node_modules/` subdirectories.
For each nested package, resolves which version each parent needs and
creates `parent_pkg/node_modules/nested_pkg/` with the correct version.
"""
@spec link_nested(nested_info(), resolved(), String.t(), strategy()) :: :ok | {:error, term()}
def link_nested(
nested_info,
flat_lockfile,
nm_dir \\ "node_modules",
strategy \\ default_strategy()
) do
run_concurrently(nested_info, fn {nested_pkg, _} ->
original_deps = NPM.Resolver.get_original_deps(nested_pkg)
install_nested_for_parents(nested_pkg, original_deps, flat_lockfile, nm_dir, strategy)
end)
end
defp populate_cache(lockfile, platform_skipped) do
platform_skipped = platform_skipped || platform_incompatible_packages(lockfile)
lockfile
|> Enum.reject(fn {name, _} -> MapSet.member?(platform_skipped, name) end)
|> Task.async_stream(
fn {name, entry} ->
optional? = optional_dependency?(name, lockfile)
case NPM.Cache.ensure(name, entry.version, entry.tarball, entry.integrity,
optional?: optional?
) do
{:ok, :missing_optional} -> {:skip, name}
other -> other
end
end,
max_concurrency: @cache_concurrency,
timeout: 60_000
)
|> Enum.reduce({:ok, MapSet.union(platform_skipped, MapSet.new())}, fn
{:ok, {:ok, _}}, {status, skipped} -> {status, skipped}
{:ok, {:skip, name}}, {status, skipped} -> {status, MapSet.put(skipped, name)}
{:ok, {:error, reason}}, {_status, _skipped} -> {{:error, reason}, MapSet.new()}
{:exit, reason}, {_status, _skipped} -> {{:error, reason}, MapSet.new()}
end)
end
defp create_node_modules(lockfile, node_modules_dir, strategy, skipped) do
File.mkdir_p!(node_modules_dir)
tree =
lockfile
|> hoist()
|> Enum.reject(fn {name, _version} -> MapSet.member?(skipped, name) end)
expected_names = MapSet.new(tree, &elem(&1, 0))
prune(node_modules_dir, expected_names)
with :ok <-
run_concurrently(tree, fn {name, version} ->
cache_path = NPM.Cache.package_dir(name, version)
target = Path.join(node_modules_dir, name)
link_package(cache_path, target, strategy)
end) do
link_bins(node_modules_dir, tree)
end
end
defp link_package(source, target, :symlink) do
File.mkdir_p!(Path.dirname(target))
File.rm_rf!(target)
case File.ln_s(source, target) do
:ok -> :ok
{:error, _reason} -> copy_package(source, target)
end
end
defp link_package(source, target, :copy) do
copy_package(source, target)
end
defp copy_package(source, target) do
File.mkdir_p!(Path.dirname(target))
File.rm_rf!(target)
File.cp_r!(source, target)
:ok
end
defp link_local_package(source, target) do
if Path.expand(source) != Path.expand(target) do
File.mkdir_p!(Path.dirname(target))
File.rm_rf!(target)
case File.ln_s(source, target) do
:ok -> :ok
{:error, _reason} -> copy_package(source, target)
end
else
:ok
end
end
@doc """
Hoist packages for a flat `node_modules` layout.
Returns a list of `{name, version}` tuples representing the top-level
packages. When multiple versions of a package exist, the most commonly
depended-on version gets hoisted.
"""
@spec hoist(resolved()) :: [{String.t(), String.t()}]
def hoist(lockfile) do
lockfile
|> collect_all_packages()
|> pick_hoisted_versions()
end
@doc false
@spec skipped_packages(resolved()) :: MapSet.t(String.t())
def skipped_packages(lockfile), do: platform_incompatible_packages(lockfile)
defp collect_all_packages(lockfile) do
lockfile
|> Enum.reduce(%{}, fn {name, entry}, acc ->
Map.update(acc, name, [entry.version], &[entry.version | &1])
end)
end
defp pick_hoisted_versions(packages) do
Enum.map(packages, fn {name, versions} ->
version =
versions
|> Enum.frequencies()
|> Enum.max_by(&elem(&1, 1))
|> elem(0)
{name, version}
end)
end
@doc """
Remove packages from `node_modules` that are not in the expected set.
Handles both regular and scoped packages (`@scope/pkg`).
"""
@spec prune(String.t(), MapSet.t()) :: :ok
def prune(node_modules_dir, expected_names) do
entries = list_dir(node_modules_dir)
{scopes, packages} = Enum.split_with(entries, &String.starts_with?(&1, "@"))
packages
|> Enum.reject(&(MapSet.member?(expected_names, &1) or String.starts_with?(&1, ".")))
|> Enum.each(&File.rm_rf!(Path.join(node_modules_dir, &1)))
Enum.each(scopes, &prune_scope(node_modules_dir, &1, expected_names))
end
defp prune_scope(node_modules_dir, scope, expected_names) do
scope_dir = Path.join(node_modules_dir, scope)
scope_dir
|> list_dir()
|> Enum.reject(&MapSet.member?(expected_names, "#{scope}/#{&1}"))
|> Enum.each(&File.rm_rf!(Path.join(scope_dir, &1)))
if list_dir(scope_dir) == [], do: File.rmdir(scope_dir)
end
@doc """
Create `node_modules/.bin/` symlinks for packages with `bin` entries.
Reads each package's `package.json` for the `bin` field and creates
executable symlinks in `.bin/`.
"""
@spec link_bins(String.t(), [{String.t(), String.t()}]) :: :ok
def link_bins(node_modules_dir, tree) do
bin_dir = Path.join(node_modules_dir, ".bin")
bins = Enum.flat_map(tree, &read_package_bins(node_modules_dir, &1))
if bins != [] do
File.mkdir_p!(bin_dir)
Enum.each(bins, fn {command, target_path} ->
link = Path.join(bin_dir, command)
File.rm(link)
File.ln_s!(target_path, link)
File.chmod(target_path, 0o755)
end)
end
:ok
end
defp read_package_bins(node_modules_dir, {name, _version}) do
pkg_json_path = Path.join([node_modules_dir, name, "package.json"])
case File.read(pkg_json_path) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
pkg_dir = Path.join(node_modules_dir, name)
parse_bin_field(data, pkg_dir)
{:error, _} ->
[]
end
end
defp parse_bin_field(%{"bin" => bin} = data, pkg_dir) when is_binary(bin) do
[{bin_command_name(data, pkg_dir), Path.expand(bin, pkg_dir)}]
end
defp parse_bin_field(%{"bin" => bin}, pkg_dir) when is_map(bin) do
Enum.map(bin, fn {command, path} -> {command, Path.expand(path, pkg_dir)} end)
end
defp parse_bin_field(%{"directories" => %{"bin" => bin_dir}}, pkg_dir) do
dir = Path.join(pkg_dir, bin_dir)
dir
|> list_dir()
|> Enum.map(fn file -> {Path.rootname(file), Path.join(dir, file)} end)
end
defp parse_bin_field(_data, _pkg_dir), do: []
defp bin_command_name(%{"name" => name}, _pkg_dir) do
case String.split(name, "/") do
[_scope, pkg] -> pkg
_ -> name
end
end
defp bin_command_name(_data, pkg_dir), do: Path.basename(pkg_dir)
defp install_nested_for_parents(nested_pkg, original_deps, flat_lockfile, nm_dir, strategy) do
flat_lockfile
|> Enum.each(fn {parent_name, parent_entry} ->
version = parent_entry_version(parent_entry)
range = if is_binary(version), do: Map.get(original_deps, "#{parent_name}@#{version}")
if is_binary(range) do
nested_version = resolve_nested_version(nested_pkg, range)
install_single_nested(nested_pkg, nested_version, parent_name, nm_dir, strategy)
end
end)
:ok
end
defp parent_entry_version(%{version: version}) when is_binary(version), do: version
defp parent_entry_version(%{"version" => version}) when is_binary(version), do: version
defp parent_entry_version(version) when is_binary(version), do: version
defp parent_entry_version(_entry), do: nil
@doc false
def __parent_entry_version__(entry) do
parent_entry_version(entry)
end
defp resolve_nested_version(name, range) do
case NPM.Registry.get_packument(name) do
{:ok, packument} ->
packument.versions
|> Map.keys()
|> Enum.filter(fn v ->
version_matches?(v, range) and match?({:ok, _}, Version.parse(v))
end)
|> Enum.sort(&version_gt?/2)
|> List.first()
_ ->
nil
end
end
defp version_matches?(version, range) do
NPMSemver.matches?(version, range)
rescue
ArgumentError -> false
end
defp version_gt?(a, b) do
Version.compare(Version.parse!(a), Version.parse!(b)) == :gt
end
defp platform_incompatible_packages(lockfile) do
optional_names =
lockfile
|> Enum.flat_map(fn {_pkg, entry} ->
Map.keys(Map.get(entry, :optional_dependencies, %{}))
end)
|> MapSet.new()
lockfile
|> Enum.filter(fn {name, _} -> MapSet.member?(optional_names, name) end)
|> Enum.reject(fn {name, entry} -> platform_compatible?(name, entry.version) end)
|> MapSet.new(fn {name, _} -> name end)
end
defp platform_compatible?(name, version) do
with {:ok, packument} <- NPM.Registry.get_packument(name),
%{} = info <- Map.get(packument.versions, version) do
NPM.Platform.os_compatible?(info.os) and NPM.Platform.cpu_compatible?(info.cpu)
else
nil -> false
_ -> true
end
end
defp optional_dependency?(name, lockfile) do
Enum.any?(lockfile, fn {_pkg, entry} ->
Map.has_key?(Map.get(entry, :optional_dependencies, %{}), name)
end)
end
defp install_single_nested(_pkg, nil, _parent, _nm_dir, _strategy), do: :ok
defp install_single_nested(pkg, version, parent, nm_dir, strategy) do
with {:ok, packument} <- NPM.Registry.get_packument(pkg),
%{} = info <- Map.get(packument.versions, version),
{:ok, cache_result} <-
NPM.Cache.ensure(pkg, version, info.dist.tarball, info.dist.integrity) do
if cache_result != :missing_optional do
cache_path = NPM.Cache.package_dir(pkg, version)
target = Path.join([nm_dir, parent, "node_modules", pkg])
link_package(cache_path, target, strategy)
else
:ok
end
else
_ -> :ok
end
end
defp list_dir(path) do
case File.ls(path) do
{:ok, entries} -> entries
{:error, _} -> []
end
end
defp default_strategy do
:symlink
end
defp run_concurrently(items, fun) do
items
|> Task.async_stream(fun,
max_concurrency: @link_concurrency,
ordered: false,
timeout: :infinity
)
|> Enum.reduce_while(:ok, fn
{:ok, :ok}, :ok -> {:cont, :ok}
{:ok, nil}, :ok -> {:cont, :ok}
{:ok, {:error, reason}}, :ok -> {:halt, {:error, reason}}
{:ok, _result}, :ok -> {:cont, :ok}
{:exit, reason}, :ok -> {:halt, {:error, reason}}
end)
end
end