Skip to content

Commit dbff439

Browse files
committed
JS engines supporting node should prefer local installed npm
Instead of using webjars npm
1 parent 732bef3 commit dbff439

File tree

5 files changed

+112
-32
lines changed

5 files changed

+112
-32
lines changed

build.sbt

+1-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ libraryDependencies ++= Seq(
2323
"io.apigee.trireme" % "trireme-node10src" % "0.9.4",
2424

2525
// NPM
26-
"org.webjars" % "npm" % "5.0.0-2",
26+
"org.webjars" % "npm" % "5.0.0-2", // we are currently stuck: https://github.com/webjars/webjars/issues/1926
2727
"org.webjars" % "webjars-locator-core" % "0.52",
2828

2929
// Test deps

src/main/scala/com/typesafe/sbt/jse/SbtJsEngine.scala

+8-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ object JsEngineImport {
3434
@deprecated("No longer used", "1.3.0")
3535
val npmTimeout = SettingKey[FiniteDuration]("jse-npm-timeout", "The maximum number amount of time for npm to do its thing.")
3636
val npmNodeModules = TaskKey[Seq[File]]("jse-npm-node-modules", "Node module files generated by NPM.")
37+
val npmPreferSystemInstalledNpm = SettingKey[Boolean]("jse-npm-prefer-system-installed-npm","Prefer detecting and using locally installed NPM when using a local engine that provides Node support")
38+
// TODO: Run install, update or ci ?
3739
}
3840

3941
}
@@ -80,7 +82,9 @@ object SbtJsEngine extends AutoPlugin {
8082
private lazy val autoDetectNode: Boolean = {
8183
val nodeExists = Try(Process("node --version").!!).isSuccess
8284
if (!nodeExists) {
85+
println("!!!")
8386
println("Warning: node.js detection failed, sbt will use the Rhino based Trireme JavaScript engine instead to run JavaScript assets compilation, which in some cases may be orders of magnitude slower than using node.js.")
87+
println("!!!")
8488
}
8589
nodeExists
8690
}
@@ -96,10 +100,11 @@ object SbtJsEngine extends AutoPlugin {
96100
val nodeModulesDirectory = (Plugin / webJarsNodeModulesDirectory).value
97101
val logger = streams.value.log
98102
val baseDir = baseDirectory.value
103+
val preferSystemInstalledNpm = npmPreferSystemInstalledNpm.value
99104

100105
val runUpdate = FileFunction.cached(cacheDirectory, FilesInfo.hash) { _ =>
101106
if (npmPackageJson.exists) {
102-
val npm = new Npm(engine, nodeModulesDirectory / "npm" / "lib" / "npm.js")
107+
val npm = new Npm(engine, Some(nodeModulesDirectory / "npm" / "lib" / "npm.js"), preferSystemNpm = preferSystemInstalledNpm)
103108
val result = npm.update(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_))
104109
if (result.exitValue != 0) {
105110
sys.error("Problems with NPM resolution. Aborting build.")
@@ -125,7 +130,8 @@ object SbtJsEngine extends AutoPlugin {
125130
println(s"Unknown engine type $engineTypeStr for sbt.jse.engineType. Resorting back to the default of $defaultEngineType.")
126131
defaultEngineType
127132
}),
128-
command := sys.props.get("sbt.jse.command").map(file)
133+
command := sys.props.get("sbt.jse.command").map(file),
134+
npmPreferSystemInstalledNpm := true,
129135

130136
) ++ inConfig(Assets)(jsEngineUnscopedSettings) ++ inConfig(TestAssets)(jsEngineUnscopedSettings)
131137

src/main/scala/com/typesafe/sbt/jse/engines/LocalEngine.scala

+22-22
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,7 @@ import scala.collection.JavaConverters._
66
import scala.collection.immutable
77
import scala.sys.process.{Process, ProcessLogger}
88

9-
class LocalEngine(stdArgs: immutable.Seq[String], stdEnvironment: Map[String, String], override val isNode: Boolean) extends Engine {
10-
11-
// This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002
12-
// The JDK can't change due to its backward compatibility requirements, but we have no such constraint
13-
// here. Args should be able to be expressed consistently by the user of our API no matter whether
14-
// execution is on Windows or not.
15-
private def needsQuoting(s: String): Boolean =
16-
if (s.isEmpty) true else s.exists(c => c == ' ' || c == '\t' || c == '\\' || c == '"')
17-
18-
private def winQuote(s: String): String = {
19-
if (!needsQuoting(s)) {
20-
s
21-
} else {
22-
"\"" + s.replaceAll("([\\\\]*)\"", "$1$1\\\\\"").replaceAll("([\\\\]*)\\z", "$1$1") + "\""
23-
}
24-
}
25-
26-
private val isWindows: Boolean = System.getProperty("os.name").toLowerCase.contains("win")
27-
28-
private def prepareArgs(args: immutable.Seq[String]): immutable.Seq[String] =
29-
if (isWindows) args.map(winQuote) else args
9+
class LocalEngine(val stdArgs: immutable.Seq[String], val stdEnvironment: Map[String, String], override val isNode: Boolean) extends Engine {
3010

3111
override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String],
3212
stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = {
@@ -35,7 +15,7 @@ class LocalEngine(stdArgs: immutable.Seq[String], stdEnvironment: Map[String, St
3515
val allEnvironment = stdEnvironment ++ environment
3616

3717

38-
val pb = new ProcessBuilder(prepareArgs(allArgs).asJava)
18+
val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava)
3919
pb.environment().putAll(allEnvironment.asJava)
4020
JsExecutionResult(Process(pb).!(ProcessLogger(stdOutSink, stdErrSink)))
4121
}
@@ -56,6 +36,26 @@ object LocalEngine {
5636
val newNodePath = Option(System.getenv("NODE_PATH")).fold(nodePath)(_ + nodePathDelim + nodePath)
5737
if (newNodePath.isEmpty) Map.empty[String, String] else Map("NODE_PATH" -> newNodePath)
5838
}
39+
40+
// This quoting functionality is as recommended per http://bugs.java.com/view_bug.do?bug_id=6511002
41+
// The JDK can't change due to its backward compatibility requirements, but we have no such constraint
42+
// here. Args should be able to be expressed consistently by the user of our API no matter whether
43+
// execution is on Windows or not.
44+
private def needsQuoting(s: String): Boolean =
45+
if (s.isEmpty) true else s.exists(c => c == ' ' || c == '\t' || c == '\\' || c == '"')
46+
47+
private def winQuote(s: String): String = {
48+
if (!needsQuoting(s)) {
49+
s
50+
} else {
51+
"\"" + s.replaceAll("([\\\\]*)\"", "$1$1\\\\\"").replaceAll("([\\\\]*)\\z", "$1$1") + "\""
52+
}
53+
}
54+
55+
private val isWindows: Boolean = System.getProperty("os.name").toLowerCase.contains("win")
56+
57+
private[jse] def prepareArgs(args: immutable.Seq[String]): immutable.Seq[String] =
58+
if (isWindows) args.map(winQuote) else args
5959
}
6060

6161
/**

src/main/scala/com/typesafe/sbt/jse/npm/Npm.scala

+78-5
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,105 @@ package com.typesafe.sbt.jse.npm
33

44
import java.io.File
55

6-
import com.typesafe.sbt.jse.engines.{Engine, JsExecutionResult}
6+
import com.typesafe.sbt.jse.engines.{Engine, JsExecutionResult, LocalEngine}
77

88
import scala.collection.immutable
9+
import scala.collection.JavaConverters._
910
import scala.collection.mutable.ListBuffer
11+
import scala.sys.process.{Process, ProcessLogger}
12+
import scala.util.Try
1013

1114
/**
1215
* A JVM class for performing NPM commands. Requires a JS engine to use.
1316
*/
14-
class Npm(engine: Engine, npmFile: File, verbose: Boolean = false) {
17+
class Npm(engine: Engine, npmFile: Option[File] = None, preferSystemNpm: Boolean = true, verbose: Boolean = false) {
1518

16-
def this(engine: Engine, npmFile: File) = this(engine, npmFile, false)
19+
@deprecated("Use one of the other non-deprecated constructors instead", "1.3.0")
20+
def this(engine: Engine, npmFile: File) = this(engine, Some(npmFile))
1721

22+
@deprecated("Use one of the other non-deprecated constructors instead", "1.3.0")
23+
def this(engine: Engine, npmFile: File, verbose: Boolean) = this(engine, Some(npmFile), verbose)
24+
25+
/**
26+
* https://docs.npmjs.com/cli/commands/npm-install
27+
*/
28+
def install(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
29+
cmd("install", global, names, outSink, errSink)
30+
}
31+
32+
/**
33+
* https://docs.npmjs.com/cli/commands/npm-update
34+
*/
1835
def update(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
36+
cmd("update", global, names, outSink, errSink)
37+
}
38+
39+
/**
40+
* https://docs.npmjs.com/cli/commands/npm-ci
41+
*/
42+
def ci(names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
43+
cmd("ci", false, names, outSink, errSink) // ci subcommand does not support -g (global) flag
44+
}
45+
46+
private def cmd(cmd: String, global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
1947
val args = ListBuffer[String]()
20-
args += "update"
48+
args += cmd
2149
if (global) args += "-g"
2250
if (verbose) args += "--verbose"
2351
args ++= names
2452
invokeNpm(args, outSink, errSink)
2553
}
2654

55+
private def detectNpm(command: String): Option[String] = {
56+
val npmExists = Try(Process(s"$command --version").!!).isSuccess
57+
if (!npmExists) {
58+
println("!!!")
59+
println(s"Warning: npm detection failed. Tried the command: $command")
60+
println("!!!")
61+
None
62+
} else {
63+
Some(command)
64+
}
65+
}
66+
2767
private def invokeNpm(args: ListBuffer[String], outSink: String => Unit, errSink: String => Unit): JsExecutionResult = {
2868
if (!engine.isNode) {
2969
throw new IllegalStateException("node not found: a Node.js installation is required to run npm.")
3070
}
31-
engine.executeJs(npmFile, args.to[immutable.Seq], Map.empty, outSink, errSink)
71+
72+
def executeJsNpm(): JsExecutionResult =
73+
engine.executeJs(npmFile.getOrElse(throw new RuntimeException("No NPM JavaScript file passed to the Npm instance via the npmFile param")), args.to[immutable.Seq], Map.empty, outSink, errSink)
74+
75+
engine match {
76+
case localEngine: LocalEngine if preferSystemNpm =>
77+
// The first argument always is the command of the js engine, e.g. either just "node", "phantomjs,.. or a path like "/usr/bin/node"
78+
// So, if the command is a path, we first try to detect if there is a npm command available in the same folder
79+
val localEngineCmd = new File(localEngine.stdArgs.head)
80+
val localNpmCmd = if (localEngineCmd.getParent() == null) {
81+
// Pretty sure the command was not a path but just something like "node"
82+
// Therefore we assume the npm command is on the operating system path, just like the js engine command
83+
detectNpm("npm")
84+
} else {
85+
// Looks like the command was a valid path, so let's see if we can detect a npm command within the same folder
86+
// If we can't, try to fallback to a npm command that is on the operating system path
87+
val cmdPath = new File(localEngineCmd.getParentFile, "npm").getCanonicalPath
88+
detectNpm(cmdPath).orElse(detectNpm("npm"))
89+
}
90+
localNpmCmd match {
91+
case Some(cmd) =>
92+
val allArgs = immutable.Seq(cmd) ++ args
93+
val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava)
94+
pb.environment().putAll(localEngine.stdEnvironment.asJava)
95+
JsExecutionResult(Process(pb).!(ProcessLogger(outSink, errSink)))
96+
case None =>
97+
println("!!!")
98+
println(s"Warning: npm detection failed. Falling back to npm provided by WebJars, which is outdated and will not work with Node versions 14 and newer.")
99+
println("!!!")
100+
executeJsNpm()
101+
}
102+
case _ => // e.g. Trireme provides node, but is not a local install and does not provide npm, therefore fallback using the webjar npm
103+
executeJsNpm()
104+
}
32105
}
33106

34107
}

src/test/scala/com/typesafe/sbt/jse/npm/NpmSpec.scala

+3-2
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ class NpmSpec extends Specification {
1919
val err = new StringBuilder
2020

2121
val to = new File(new File("target"), "webjars")
22-
val npm = new Npm(Node(), NpmLoader.load(to, this.getClass.getClassLoader), verbose = true)
22+
val npm = new Npm(Node(), Some(NpmLoader.load(to, this.getClass.getClassLoader)), verbose = true)
2323
val result = npm.update(false, Nil, s => {
2424
println("stdout: " + s)
2525
out.append(s + "\n")
@@ -32,7 +32,8 @@ class NpmSpec extends Specification {
3232
val stdOut = out.toString()
3333

3434
result.exitValue must_== 0
35-
stdErr must contain("npm http request GET https://registry.npmjs.org/amdefine")
35+
stdErr must contain("npm http request GET https://registry.npmjs.org/amdefine") // when using webjar npm
36+
.or(contain("npm http fetch GET 200 https://registry.npmjs.org/amdefine")) // when using local installed npm
3637
}
3738
}
3839
}

0 commit comments

Comments
 (0)