defmodule NPM.Node.Runner do alias NPM.Node.Exec @moduledoc """ Runs JavaScript entrypoints with Node.js using project `node_modules` resolution. npm package binaries are often symlinks into package directories and expect Node's module resolver to see the project's `node_modules`. This module writes a short temporary ESM loader that configures `require`, `NODE_PATH`, and module lookup paths before importing the target entrypoint. It is used by `mix npm.exec` and related helpers when an installed package binary needs to execute through Node rather than through a shell script. """ @spec run(String.t(), [String.t()], keyword()) :: {String.t(), non_neg_integer()} def run(entrypoint, args, opts \\ []) do node_modules_dir = Path.expand(Keyword.get(opts, :node_modules_dir, "node_modules")) env = Keyword.get(opts, :env, []) ++ Exec.env(node_modules_dir) entrypoint = resolve_entrypoint(Path.expand(entrypoint), node_modules_dir) cd = Keyword.get(opts, :cd, File.cwd!()) if native_binary?(entrypoint) do System.cmd(entrypoint, args, env: env, stderr_to_stdout: true, cd: cd) else run_with_node(entrypoint, args, node_modules_dir, env, cd) end end defp run_with_node(entrypoint, args, node_modules_dir, env, cd) do if package_script?(entrypoint, node_modules_dir) do node_args = [entrypoint | args] System.cmd("node", node_args, env: env, stderr_to_stdout: true, cd: cd) else loader_path = write_loader(node_modules_dir, entrypoint) try do node_args = ["--preserve-symlinks", "--preserve-symlinks-main", loader_path | args] System.cmd("node", node_args, env: env, stderr_to_stdout: true, cd: cd) after File.rm(loader_path) end end end defp package_script?(entrypoint, node_modules_dir) do String.starts_with?(entrypoint, node_modules_dir) end @js_exts ~w(.js .mjs .cjs .ts) defp native_binary?(path) do Path.extname(path) not in @js_exts and not node_script?(path) end defp node_script?(path) do case File.open(path, [:read, :binary]) do {:ok, io} -> header = IO.binread(io, 64) File.close(io) match?(<<"#!", rest::binary>> when rest != "", header) and header_mentions_node?(header) _ -> false end end defp header_mentions_node?(<<"#!", rest::binary>>), do: String.contains?(rest, "node") defp header_mentions_node?(_), do: false defp resolve_entrypoint(entrypoint, node_modules_dir) do bin_dir = Path.join(node_modules_dir, ".bin") if String.starts_with?(entrypoint, bin_dir) do command = Path.basename(entrypoint) case find_package_entrypoint(command, node_modules_dir) do {:ok, resolved} -> resolved :error -> entrypoint end else entrypoint end end defp find_package_entrypoint(command, node_modules_dir) do bin_path = Path.join([node_modules_dir, ".bin", command]) real_bin_path = resolve_symlink(bin_path) with {:ok, content} <- File.read(real_bin_path), [_, rel_path] <- Regex.run(~r{import\s+["'](\.\./[^"']+)["']}, content) do {:ok, Path.expand(rel_path, Path.dirname(real_bin_path))} else _ -> case Exec.which(command, node_modules_dir) do {:ok, pkg_path} -> {:ok, Path.expand(pkg_path)} _ -> :error end end end defp resolve_symlink(path, depth \\ 10) defp resolve_symlink(path, 0), do: path defp resolve_symlink(path, depth) do case File.read_link(path) do {:ok, target} -> resolve_symlink(Path.expand(target, Path.dirname(path)), depth - 1) {:error, _} -> path end end defp write_loader(node_modules_dir, entrypoint) do path = Path.join( Path.dirname(node_modules_dir), ".npm-node-runner-#{System.unique_integer([:positive])}.mjs" ) File.write!(path, """ import { createRequire } from 'node:module' import { pathToFileURL } from 'node:url' const nmDir = #{inspect(node_modules_dir)} globalThis.require = createRequire(pathToFileURL(nmDir + '/').href) process.env.NODE_PATH = nmDir require('node:module').Module._initPaths() await import(pathToFileURL(#{inspect(entrypoint)}).href) """) path end end