defmodule NPM.Workspace do
@moduledoc """
Workspace management for npm monorepos.
Handles discovery and resolution of workspace packages defined
in the root `package.json` via the `workspaces` field.
"""
@dep_fields [
dependencies: "dependencies",
dev_dependencies: "devDependencies",
optional_dependencies: "optionalDependencies"
]
@type project :: %{
dependencies: %{String.t() => String.t()},
dev_dependencies: %{String.t() => String.t()},
optional_dependencies: %{String.t() => String.t()},
local_links: %{String.t() => String.t()}
}
@doc """
Reads dependency groups from the root package and workspace packages.
Registry dependencies are merged across the root `package.json` and every
workspace `package.json`. Local `workspace:` and directory `file:`
dependencies are returned as `:local_links` so they can be linked into the
root `node_modules/` without registry resolution.
"""
@spec read_all(String.t()) :: {:ok, project()} | {:error, term()}
def read_all(root_dir \\ ".") do
with {:ok, manifests} <- manifests(root_dir),
{:ok, local_links} <- collect_local_links(manifests),
{:ok, groups} <- dependency_groups(manifests, local_links) do
{:ok, Map.put(groups, :local_links, local_links)}
end
end
@doc """
Returns the dependency map that should be resolved for install.
"""
@spec install_dependencies(project(), keyword()) ::
{:ok, %{String.t() => String.t()}} | {:error, term()}
def install_dependencies(project, opts \\ []) do
groups =
if opts[:production] do
[project.dependencies, project.optional_dependencies]
else
[project.dependencies, project.dev_dependencies, project.optional_dependencies]
end
merge_dependency_maps(groups)
end
@doc """
Reads root and workspace package manifests.
"""
@spec manifests(String.t()) :: {:ok, [map()]} | {:error, term()}
def manifests(root_dir \\ ".") do
root_dir = Path.expand(root_dir)
root_path = Path.join(root_dir, "package.json")
with {:ok, root_data} <- read_manifest(root_path),
{:ok, workspace_patterns} <- NPM.Package.JSON.read_workspaces(root_path),
{:ok, workspace_manifests} <- read_workspace_manifests(root_dir, workspace_patterns) do
{:ok,
[%{path: root_path, dir: root_dir, root?: true, data: root_data} | workspace_manifests]}
end
end
@doc """
Discovers workspace packages from the root package.json.
Reads the `workspaces` field and resolves glob patterns to actual
package directories. Returns a list of workspace info maps.
"""
@spec discover(String.t()) :: {:ok, [map()]} | {:error, term()}
def discover(root_dir \\ ".") do
pkg_path = Path.join(root_dir, "package.json")
with {:ok, workspaces} <- NPM.Package.JSON.read_workspaces(pkg_path),
packages <- resolve_workspaces(workspaces, root_dir) do
{:ok, packages}
end
end
@doc """
Returns a list of workspace package names.
"""
@spec names(String.t()) :: {:ok, [String.t()]} | {:error, term()}
def names(root_dir \\ ".") do
case discover(root_dir) do
{:ok, packages} -> {:ok, Enum.map(packages, & &1.name)}
error -> error
end
end
@doc """
Returns a dependency graph of inter-workspace dependencies.
Finds which workspace packages depend on other workspace packages.
"""
@spec dep_graph([map()]) :: %{String.t() => [String.t()]}
def dep_graph(packages) do
ws_names = MapSet.new(Enum.map(packages, & &1.name))
Map.new(packages, fn pkg ->
internal_deps =
pkg.dependencies
|> Map.keys()
|> Enum.filter(&MapSet.member?(ws_names, &1))
|> Enum.sort()
{pkg.name, internal_deps}
end)
end
@doc """
Returns the topological build order for workspace packages.
Packages with no inter-workspace dependencies come first.
"""
@spec build_order([map()]) :: [String.t()]
def build_order(packages) do
graph = dep_graph(packages)
topo_sort(graph)
end
@doc """
Checks if a directory is a workspace root (has workspaces field).
"""
@spec workspace_root?(String.t()) :: boolean()
def workspace_root?(dir \\ ".") do
case NPM.Package.JSON.read_workspaces(Path.join(dir, "package.json")) do
{:ok, ws} when ws != [] -> true
_ -> false
end
end
defp resolve_workspaces(patterns, base_dir) do
patterns
|> NPM.Package.JSON.expand_workspaces(base_dir)
|> Enum.flat_map(&read_workspace_package/1)
end
defp read_workspace_package(ws_dir) do
pkg_path = Path.join(ws_dir, "package.json")
case File.read(pkg_path) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
[
%{
name: data["name"] || Path.basename(ws_dir),
version: data["version"] || "0.0.0",
path: ws_dir,
dependencies: Map.merge(data["dependencies"] || %{}, data["devDependencies"] || %{})
}
]
_ ->
[]
end
end
defp topo_sort(adj) do
g = :digraph.new()
try do
Enum.each(adj, fn {name, _} -> :digraph.add_vertex(g, name) end)
Enum.each(adj, fn {name, deps} ->
Enum.each(deps, fn dep ->
:digraph.add_vertex(g, dep)
:digraph.add_edge(g, dep, name)
end)
end)
case :digraph_utils.topsort(g) do
false -> Map.keys(adj)
sorted -> sorted
end
after
:digraph.delete(g)
end
end
defp read_workspace_manifests(root_dir, patterns) do
manifests =
patterns
|> NPM.Package.JSON.expand_workspaces(root_dir)
|> Enum.map(&Path.expand/1)
|> Enum.uniq()
|> Enum.sort()
|> Enum.reduce_while({:ok, []}, fn dir, {:ok, acc} ->
path = Path.join(dir, "package.json")
case read_manifest(path) do
{:ok, data} -> {:cont, {:ok, [%{path: path, dir: dir, root?: false, data: data} | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
case manifests do
{:ok, list} -> {:ok, Enum.reverse(list)}
error -> error
end
end
defp read_manifest(path) do
case NPM.JSON.read_file(path) do
{:ok, data} when is_map(data) -> {:ok, data}
{:ok, _} -> {:error, {:invalid_package_json, path}}
{:error, :enoent} -> {:ok, %{}}
{:error, reason} -> {:error, reason}
end
end
defp collect_local_links(manifests) do
workspace_links = workspace_package_links(manifests)
manifests
|> dependency_entries()
|> Enum.reduce_while({:ok, workspace_links}, fn {manifest, name, range}, {:ok, acc} ->
cond do
workspace_range?(range) ->
if Map.has_key?(acc, name) do
{:cont, {:ok, acc}}
else
{:halt, {:error, {:unknown_workspace_dependency, name, manifest.path}}}
end
file_range?(range) ->
case file_link(name, range, manifest.dir) do
{:ok, {link_name, path}} -> put_local_link(acc, link_name, path)
{:error, reason} -> {:halt, {:error, reason}}
end
true ->
{:cont, {:ok, acc}}
end
end)
end
defp workspace_package_links(manifests) do
manifests
|> Enum.reject(& &1.root?)
|> Enum.flat_map(fn manifest ->
case package_name(manifest) do
nil -> []
name -> [{name, manifest.dir}]
end
end)
|> Map.new()
end
defp package_name(%{data: data, dir: dir}) do
case Map.get(data, "name") do
name when is_binary(name) and name != "" -> name
_ -> Path.basename(dir)
end
end
defp dependency_entries(manifests) do
Enum.flat_map(manifests, fn manifest ->
Enum.flat_map(@dep_fields, fn {_key, field} ->
manifest.data
|> Map.get(field, %{})
|> map_entries()
|> Enum.map(fn {name, range} -> {manifest, name, range} end)
end)
end)
end
defp dependency_groups(manifests, local_links) do
initial = %{dependencies: %{}, dev_dependencies: %{}, optional_dependencies: %{}}
Enum.reduce_while(manifests, {:ok, initial}, fn manifest, {:ok, acc} ->
case merge_manifest_dependencies(acc, manifest, local_links) do
{:ok, updated} -> {:cont, {:ok, updated}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp merge_manifest_dependencies(acc, manifest, local_links) do
Enum.reduce_while(@dep_fields, {:ok, acc}, fn {key, field}, {:ok, current} ->
deps = manifest.data |> Map.get(field, %{}) |> map_entries()
case merge_dependency_group(Map.fetch!(current, key), deps, manifest, local_links) do
{:ok, group} -> {:cont, {:ok, Map.put(current, key, group)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp merge_dependency_group(group, deps, manifest, local_links) do
Enum.reduce_while(deps, {:ok, group}, fn {name, range}, {:ok, acc} ->
if local_dependency?(name, range, local_links) do
{:cont, {:ok, acc}}
else
merge_dependency(acc, name, range, manifest.path)
end
end)
end
defp merge_dependency_maps(groups) do
groups
|> Enum.reduce_while({:ok, %{}}, fn group, {:ok, acc} ->
result =
Enum.reduce_while(group, {:ok, acc}, fn {name, range}, {:ok, inner_acc} ->
merge_dependency(inner_acc, name, range, "package.json")
end)
case result do
{:ok, updated} -> {:cont, {:ok, updated}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp merge_dependency(acc, name, range, path) do
case Map.fetch(acc, name) do
{:ok, ^range} ->
{:cont, {:ok, acc}}
{:ok, existing} ->
{:halt, {:error, {:conflicting_workspace_dependency, name, existing, range, path}}}
:error ->
{:cont, {:ok, Map.put(acc, name, range)}}
end
end
defp map_entries(map) when is_map(map) do
Enum.flat_map(map, fn
{name, range} when is_binary(name) and is_binary(range) -> [{name, range}]
_ -> []
end)
end
defp map_entries(_), do: []
defp local_dependency?(name, range, local_links) do
Map.has_key?(local_links, name) or workspace_range?(range) or file_range?(range)
end
defp workspace_range?("workspace:" <> _), do: true
defp workspace_range?(_), do: false
defp file_range?("file:" <> _), do: true
defp file_range?(_), do: false
defp file_link(name, "file:" <> path, base_dir) do
package_dir = Path.expand(path, base_dir)
package_json = Path.join(package_dir, "package.json")
case NPM.JSON.read_file(package_json) do
{:ok, data} when is_map(data) -> {:ok, {name, package_dir}}
{:ok, _} -> {:error, {:invalid_file_dependency, name, package_json}}
{:error, :enoent} -> {:error, {:missing_file_dependency, name, package_dir}}
{:error, reason} -> {:error, reason}
end
end
defp put_local_link(acc, name, path) do
case Map.fetch(acc, name) do
{:ok, ^path} ->
{:cont, {:ok, acc}}
{:ok, existing} ->
{:halt, {:error, {:conflicting_local_dependency, name, existing, path}}}
:error ->
{:cont, {:ok, Map.put(acc, name, path)}}
end
end
end