├── project ├── build.properties └── plugins.sbt ├── sbt-js-engine-tester ├── project │ ├── build.properties │ └── plugins.sbt ├── build.sbt └── package.json ├── NOTICE ├── src ├── sbt-test │ └── sbt-js-engine │ │ ├── jstask │ │ ├── src │ │ │ └── main │ │ │ │ └── assets │ │ │ │ ├── _b.js │ │ │ │ └── a.js │ │ ├── build.sbt │ │ ├── project │ │ │ ├── plugins.sbt │ │ │ ├── SbtHelloJsTask.scala │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── resources │ │ │ │ └── hello-shell.js │ │ └── test │ │ ├── npm │ │ ├── build.sbt │ │ ├── package1.json │ │ ├── package2.json │ │ ├── project │ │ │ └── plugins.sbt │ │ └── test │ │ └── npmsubmodule │ │ ├── package.json │ │ ├── subproject │ │ └── package.json │ │ ├── project │ │ └── plugins.sbt │ │ ├── build.sbt │ │ └── test ├── test │ ├── resources │ │ └── com │ │ │ └── typesafe │ │ │ └── sbt │ │ │ └── jse │ │ │ └── engines │ │ │ ├── test-javax.js │ │ │ ├── test-node.js │ │ │ ├── hello.js │ │ │ └── test-rhino.js │ └── scala │ │ └── com │ │ └── typesafe │ │ └── sbt │ │ └── jse │ │ ├── engines │ │ ├── RhinoSpec.scala │ │ ├── JavaxEngineSpec.scala │ │ └── TriremeSpec.scala │ │ ├── npm │ │ └── NpmSpec.scala │ │ ├── helpers.scala │ │ ├── gens.scala │ │ └── SbtJsTaskPluginSpec.scala └── main │ ├── scala │ └── com │ │ └── typesafe │ │ └── sbt │ │ └── jse │ │ ├── engines │ │ ├── Engine.scala │ │ ├── LineSinkWriter.scala │ │ ├── JavaxEngine.scala │ │ ├── Trireme.scala │ │ ├── Rhino.scala │ │ └── LocalEngine.scala │ │ ├── npm │ │ └── Npm.scala │ │ ├── SbtJsEngine.scala │ │ └── SbtJsTask.scala │ ├── scala-2.12 │ └── com │ │ └── typesafe │ │ └── sbt │ │ └── PluginCompat.scala │ └── scala-3 │ └── com │ └── typesafe │ └── sbt │ └── PluginCompat.scala ├── package.json ├── .gitignore ├── LICENSE ├── .github ├── scala-steward.conf └── workflows │ ├── build-test.yml │ └── publish.yml ├── README.md └── CONTRIBUTING.md /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.4 2 | -------------------------------------------------------------------------------- /sbt-js-engine-tester/project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.11.1 2 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013-2014 Typesafe Inc. -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/src/main/assets/_b.js: -------------------------------------------------------------------------------- 1 | // Some more content -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/src/main/assets/a.js: -------------------------------------------------------------------------------- 1 | // Some file contents -------------------------------------------------------------------------------- /src/test/resources/com/typesafe/sbt/jse/engines/test-javax.js: -------------------------------------------------------------------------------- 1 | print(arguments[0]); -------------------------------------------------------------------------------- /sbt-js-engine-tester/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")).enablePlugins(SbtWeb) 2 | 3 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")).enablePlugins(SbtWeb) -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npm/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = (project in file(".")).enablePlugins(SbtWeb) -------------------------------------------------------------------------------- /src/test/resources/com/typesafe/sbt/jse/engines/test-node.js: -------------------------------------------------------------------------------- 1 | var console = require("console"); 2 | 3 | console.log(process.argv[2]); 4 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-web-build-base" % "2.0.2") 2 | 3 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.1") 4 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npmsubmodule/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stuff", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "glob": "~4.0.6" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/test/resources/com/typesafe/sbt/jse/engines/hello.js: -------------------------------------------------------------------------------- 1 | // A simple CommonJS module 2 | exports.sayHello = function (you) { 3 | return "Hello " + you; 4 | }; 5 | 6 | -------------------------------------------------------------------------------- /sbt-js-engine-tester/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "somename", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "console-browserify": "0.1.6" 6 | } 7 | } -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npm/package1.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "somename", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "console-browserify": "0.1.6" 6 | } 7 | } -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npm/package2.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "somename2", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "console-browserify": "0.1.6" 6 | } 7 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "somename", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "amdefine": "^1.0.1", 6 | "@grpc/grpc-js": "^1.13.4" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npmsubmodule/subproject/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subproject-stuff", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "mkdirp": "~0.5.0" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /sbt-js-engine-tester/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | lazy val root = Project("plugins", file(".")).dependsOn(plugin) 2 | 3 | lazy val plugin = ProjectRef(file("../").getCanonicalFile.toURI, "sbt-js-engine") 4 | 5 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npm/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-js-engine" % sys.props("project.version")) 2 | 3 | resolvers ++= Seq( 4 | Resolver.sonatypeRepo("snapshots"), 5 | ) 6 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npmsubmodule/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-js-engine" % sys.props("project.version")) 2 | 3 | resolvers ++= Seq( 4 | Resolver.sonatypeRepo("snapshots"), 5 | ) 6 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.github.sbt" % "sbt-js-engine" % sys.props("project.version")) 2 | 3 | resolvers ++= Seq( 4 | Resolver.sonatypeRepo("snapshots"), 5 | ) 6 | 7 | libraryDependencies ++= Seq( 8 | "org.webjars" % "mkdirp" % "0.3.5" 9 | ) 10 | -------------------------------------------------------------------------------- /src/test/resources/com/typesafe/sbt/jse/engines/test-rhino.js: -------------------------------------------------------------------------------- 1 | // Check that we have Rhino shell methods in scope 2 | readFile("src/test/resources/com/typesafe/sbt/jse/engines/hello.js"); 3 | 4 | // Check CommonJS support 5 | var sayHello = require("hello").sayHello; 6 | 7 | print(sayHello(arguments[0])); -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npmsubmodule/build.sbt: -------------------------------------------------------------------------------- 1 | lazy val subproject = (project in file("./subproject")).enablePlugins(SbtWeb) 2 | 3 | lazy val root = (project in file(".")).enablePlugins(SbtWeb) 4 | .settings( 5 | name := """sbt-web-sub-module-npm""", 6 | version := "1.0-SNAPSHOT", 7 | scalaVersion := "2.11.1" 8 | ) 9 | 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.class 2 | *.log 3 | 4 | # sbt specific 5 | dist/* 6 | target/ 7 | lib_managed/ 8 | src_managed/ 9 | project/boot/ 10 | project/plugins/project/ 11 | 12 | # sbt-web 13 | node_modules/ 14 | package-lock.json 15 | 16 | # Scala-IDE specific 17 | .scala_dependencies 18 | 19 | # Idea 20 | .idea/ 21 | .idea_modules/ 22 | 23 | package 24 | 25 | .bsp/ 26 | 27 | .sdkmanrc 28 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npmsubmodule/test: -------------------------------------------------------------------------------- 1 | # There should be no node modules as there is no package.json visible. 2 | > Assets/webNodeModules 3 | $ exists node_modules/glob 4 | -$ exists subproject/node_modules/mkdirp 5 | 6 | > subproject/Assets/webNodeModules 7 | $ exists node_modules/glob 8 | -$ exists node_modules/mkdirp 9 | $ exists subproject/node_modules/mkdirp 10 | -$ exists subproject/node_modules/glob 11 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/test: -------------------------------------------------------------------------------- 1 | > set Assets / HelloJsTaskKeys.hello / excludeFilter := GlobFilter("_b.js") 2 | > hello 3 | $ exists target/**/web/hello/main/a.js 4 | -$ exists target/web/hello/main/_b.js 5 | 6 | > clean 7 | > set HelloJsTaskKeys.compress := true 8 | > hello 9 | -$ exists target/web/hello/main/a.js 10 | $ exists target/**/web/hello/main/a.min.js 11 | 12 | > set HelloJsTaskKeys.fail := true 13 | -> hello 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this project except in compliance with 4 | the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0. 5 | 6 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 7 | "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific 8 | language governing permissions and limitations under the License. -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/Engine.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import java.io.File 4 | 5 | import scala.collection.immutable 6 | 7 | /** 8 | * A JavaScript engine. JavaScript engines are intended to be short-lived and will terminate themselves on 9 | * completion of executing some JavaScript. 10 | */ 11 | trait Engine { 12 | def executeJs( 13 | source: File, args: immutable.Seq[String], environment: Map[String, String] = Map.empty, 14 | stdOutSink: String => Unit, stdErrSink: String => Unit 15 | ): JsExecutionResult 16 | 17 | def isNode: Boolean 18 | } 19 | 20 | case class JsExecutionResult(exitValue: Int) 21 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/npm/test: -------------------------------------------------------------------------------- 1 | # There should be no node modules as there is no package.json visible. 2 | > Assets/webNodeModules 3 | -$ exists node_modules/console-browserify/index.js 4 | 5 | # Unhide the package json - npm should now be invoked 6 | $ copy-file package1.json package.json 7 | > Assets/webNodeModules 8 | $ exists node_modules/console-browserify/index.js 9 | 10 | # Now change the package.json file and expect it to perform an update 11 | $ copy-file package2.json package.json 12 | > Assets/webNodeModules 13 | $ exists node_modules/console-browserify/index.js 14 | 15 | # Remove the package.json file and expect the node_modules to disappear 16 | $ delete package.json 17 | > Assets/webNodeModules 18 | -$ exists node_modules 19 | -------------------------------------------------------------------------------- /.github/scala-steward.conf: -------------------------------------------------------------------------------- 1 | pullRequests.frequency = "@monthly" 2 | 3 | commits.message = "${artifactName} ${nextVersion} (was ${currentVersion})" 4 | 5 | pullRequests.grouping = [ 6 | { name = "patches", "title" = "Patch updates", "filter" = [{"version" = "patch"}] } 7 | ] 8 | 9 | updates.pin = [ 10 | // sbt-js-engine is a sbt plugin and sbt plugins use Scala 2.12 11 | { groupId = "org.scala-lang", artifactId = "scala-compiler", version = "2.12." }, 12 | { groupId = "org.scala-lang", artifactId = "scala-library", version = "2.12." }, 13 | { groupId = "org.scala-lang", artifactId = "scala-reflect", version = "2.12." }, 14 | { groupId = "org.scala-lang", artifactId = "scalap", version = "2.12." } 15 | ] 16 | 17 | updatePullRequests = never 18 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | pull_request: 5 | 6 | push: 7 | branches: 8 | - main # Check branch after merge 9 | 10 | concurrency: 11 | # Only run once for latest commit per ref and cancel other (previous) runs. 12 | group: ci-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | tests: 17 | name: Tests 18 | uses: playframework/.github/.github/workflows/cmd.yml@v3 19 | with: 20 | java: 17, 11, 8 21 | scala: 2.12.x, 3.x 22 | cmd: | 23 | sbt ++$MATRIX_SCALA test scripted 24 | 25 | finish: 26 | name: Finish 27 | if: github.event_name == 'pull_request' 28 | needs: # Should be last 29 | - "tests" 30 | uses: playframework/.github/.github/workflows/rtm.yml@v3 31 | -------------------------------------------------------------------------------- /src/main/scala-2.12/com/typesafe/sbt/PluginCompat.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import sbt.* 4 | import sbt.Keys.Classpath 5 | import com.typesafe.sbt.web.PathMapping 6 | import xsbti.FileConverter 7 | 8 | import java.nio.file.{ Path => NioPath } 9 | 10 | private[sbt] object PluginCompat { 11 | type FileRef = java.io.File 12 | type UnhashedFileRef = java.io.File 13 | 14 | class cacheLevel(include: Array[Any]) extends annotation.StaticAnnotation 15 | def uncached[T](value: T): T = value 16 | 17 | def toFile(a: Attributed[File])(implicit conv: FileConverter): File = 18 | a.data 19 | def toSet[A](iterable: Iterable[A]): Set[A] = iterable.to[Set] 20 | def toFile(f: File)(implicit conv: FileConverter): File = f 21 | def toFileRef(f: File)(implicit conv: FileConverter): FileRef = f 22 | } -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/engines/RhinoSpec.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import org.specs2.mutable.Specification 4 | import java.io.File 5 | import scala.collection.immutable 6 | 7 | class RhinoSpec extends Specification { 8 | 9 | "The Rhino engine" should { 10 | 11 | "execute some javascript by passing in a string arg and comparing its return value" in { 12 | val f = new File(classOf[RhinoSpec].getResource("test-rhino.js").toURI) 13 | val out = new StringBuilder 14 | val err = new StringBuilder 15 | Rhino().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 16 | err.toString.trim must_== "" 17 | out.toString.trim must_== "Hello 999" 18 | } 19 | 20 | "execute some javascript by passing in a string arg and comparing its return value expecting an error" in { 21 | val f = new File(classOf[RhinoSpec].getResource("test-node.js").toURI) 22 | val out = new StringBuilder 23 | val err = new StringBuilder 24 | Rhino().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 25 | out.toString.trim must_== "" 26 | err.toString.trim must contain("""Error: Module "console" not found""") 27 | } 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/main/scala-3/com/typesafe/sbt/PluginCompat.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import java.nio.file.{ Path => NioPath } 4 | import java.io.{ File => IoFile } 5 | import sbt.* 6 | import sbt.Keys.Classpath 7 | import xsbti.{ FileConverter, HashedVirtualFileRef, VirtualFileRef } 8 | import com.typesafe.sbt.web.PathMapping 9 | 10 | private[sbt] object PluginCompat: 11 | export sbt.CacheImplicits.{ *, given } 12 | export sbt.util.cacheLevel 13 | export sbt.Def.uncached 14 | 15 | val TestResultPassed = TestResult.Passed 16 | type FileRef = HashedVirtualFileRef 17 | type UnhashedFileRef = VirtualFileRef 18 | 19 | def toNioPath(a: Attributed[HashedVirtualFileRef])(using conv: FileConverter): NioPath = 20 | conv.toPath(a.data) 21 | inline def toFile(a: Attributed[HashedVirtualFileRef])(using conv: FileConverter): File = 22 | toNioPath(a).toFile 23 | def toSet[A](iterable: Iterable[A]): Set[A] = iterable.to(Set) 24 | def toNioPath(hvf: VirtualFileRef)(using conv: FileConverter): NioPath = 25 | conv.toPath(hvf) 26 | def toFile(hvf: VirtualFileRef)(using conv: FileConverter): File = 27 | toNioPath(hvf).toFile 28 | inline def toFileRef(file: File)(using conv: FileConverter): FileRef = 29 | conv.toVirtualFile(file.toPath) 30 | end PluginCompat -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/engines/JavaxEngineSpec.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import org.specs2.mutable.Specification 4 | import java.io.File 5 | import scala.collection.immutable 6 | 7 | class JavaxEngineSpec extends Specification { 8 | 9 | args(skipAll = !(sys.props("java.specification.version") == "1.8" || sys.props("java.specification.version") == "11")) 10 | 11 | "A JavaxEngine" should { 12 | 13 | "execute some javascript by passing in a string arg and comparing its return value" in { 14 | val f = new File(classOf[JavaxEngineSpec].getResource("test-javax.js").toURI) 15 | val out = new StringBuilder 16 | val err = new StringBuilder 17 | JavaxEngine().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 18 | err.toString.trim must_== "" 19 | out.toString.trim must_== "999" 20 | } 21 | 22 | "execute some javascript by passing in a string arg and comparing its return value expecting an error" in { 23 | val f = new File(classOf[JavaxEngineSpec].getResource("test-node.js").toURI) 24 | val out = new StringBuilder 25 | val err = new StringBuilder 26 | JavaxEngine().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 27 | out.toString.trim must_== "" 28 | err.toString.trim must contain("""ReferenceError: "require" is not defined""") 29 | } 30 | 31 | } 32 | } -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/npm/NpmSpec.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.npm 2 | 3 | 4 | import java.io.File 5 | 6 | import com.typesafe.sbt.jse.engines.Node 7 | import org.specs2.mutable.Specification 8 | import sbt.io.IO 9 | 10 | class NpmSpec extends Specification { 11 | 12 | "Npm" should { 13 | "perform an update, retrieve resources, and execute node-gyp (the native compilation tool)" in { 14 | 15 | // cleanup from any past tests 16 | IO.delete(new File("node_modules")) 17 | 18 | val out = new StringBuilder 19 | val err = new StringBuilder 20 | 21 | val to = new File(new File("target"), "webjars") 22 | val npm = new Npm(Node(), Some(NpmLoader.load(to, this.getClass.getClassLoader)), verbose = true) 23 | val result = npm.update(false, Nil, s => { 24 | println("stdout: " + s) 25 | out.append(s + "\n") 26 | }, s => { 27 | println("stderr: " + s) 28 | err.append(s + "\n") 29 | }) 30 | 31 | val stdErr = err.toString() 32 | val stdOut = out.toString() 33 | 34 | result.exitValue must_== 0 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 37 | .or(contain("npm http cache https://registry.npmjs.org/amdefine")) // Just in case it's a cache hit 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/LineSinkWriter.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import java.io.{OutputStream, Writer} 4 | 5 | private[jse] class LineSinkWriter(override protected val writeLine: String => Unit) extends Writer with LineSink { 6 | 7 | override def write(cbuf: Array[Char], off: Int, len: Int): Unit = { 8 | writeLine(new String(cbuf, off, len)) 9 | } 10 | 11 | override def flush(): Unit = () 12 | 13 | override def close(): Unit = () 14 | } 15 | 16 | private[jse] class LineSinkOutputStream(override protected val writeLine: String => Unit) extends OutputStream with LineSink { 17 | 18 | override def write(b: Array[Byte], off: Int, len: Int): Unit = { 19 | // Technically unsafe since it won't handle split surrogates, however, in practice, complete characters should only 20 | // ever be written. 21 | writeLine(new String(b, off, len, "utf-8")) 22 | } 23 | 24 | override def write(b: Int): Unit = write(Array(b.asInstanceOf[Byte])) 25 | } 26 | 27 | private[jse] trait LineSink { 28 | 29 | protected val writeLine: String => Unit 30 | 31 | private var currentLine = "" 32 | 33 | protected def onWrite(str: String): Unit = synchronized { 34 | val buffer = if (currentLine.isEmpty) str else currentLine + str 35 | val lines = buffer.split("\r?\n", -1) 36 | currentLine = lines.last 37 | if (lines.length > 1) { 38 | for (i <- 0 until lines.length - 1) { 39 | writeLine(lines(i)) 40 | } 41 | } 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/helpers.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import com.typesafe.sbt.jse.SbtJsTask.JsTaskProtocol.{ProblemResultsPair, SourceResultPair} 4 | import com.typesafe.sbt.web.LineBasedProblem 5 | 6 | object helpers { 7 | 8 | // Define a custom equality function for LineBasedProblem 9 | def lineBasedProblemEquality(p1: LineBasedProblem, p2: LineBasedProblem): Boolean = 10 | p1.message == p2.message && 11 | p1.severity == p2.severity && 12 | p1.position.line == p2.position.line && 13 | p1.position.lineContent == p2.position.lineContent && 14 | p1.position.offset == p2.position.offset && 15 | p1.position.sourceFile.get.getCanonicalPath == p2.position.sourceFile.get.getCanonicalPath 16 | 17 | // Define a custom equality function for SourceResultPair 18 | def sourceResultPairEquality(p1: SourceResultPair, p2: SourceResultPair): Boolean = 19 | p1.result == p2.result && 20 | p1.source.getCanonicalPath == p2.source.getCanonicalPath 21 | 22 | private def stringifyLineBasedProblem(p: LineBasedProblem): String = 23 | s"""|LineBasedProblem( 24 | | ${p.message} 25 | | ${p.severity} 26 | | ${p.position.line.get} 27 | | ${p.position.lineContent} 28 | | ${p.position.offset.get} 29 | | ${p.position.sourceFile.get} 30 | |)""".stripMargin 31 | 32 | private def stringifyProblemResultsPair(p: ProblemResultsPair): String = 33 | s"""|ProblemResultsPair( 34 | | ${p.results} 35 | | ${p.problems.map(stringifyLineBasedProblem)} 36 | |)""".stripMargin 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: # Snapshots 6 | - main 7 | tags: ["**"] # Releases 8 | 9 | jobs: 10 | publish-artifacts: 11 | name: JDK 8 12 | runs-on: ubuntu-24.04 13 | if: ${{ github.repository_owner == 'sbt' }} 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | # we don't know what commit the last tag was it's safer to get entire repo so previousStableVersion resolves 19 | fetch-depth: 0 20 | 21 | - name: Coursier Cache 22 | id: coursier-cache 23 | uses: coursier/cache-action@v6 24 | 25 | - name: Install Adoptium Temurin OpenJDK 26 | uses: coursier/setup-action@v1 27 | with: 28 | jvm: adoptium:8 29 | 30 | - name: Install sbt 31 | uses: sbt/setup-sbt@v1 32 | 33 | - name: Publish artifacts 34 | run: sbt ci-release 35 | env: 36 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} 37 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} 38 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }} 39 | PGP_SECRET: ${{ secrets.PGP_SECRET }} 40 | 41 | - name: Cleanup before cache 42 | shell: bash 43 | run: | 44 | find $HOME/Library/Caches/Coursier/v1 -name "ivydata-*.properties" -delete || true 45 | find $HOME/.ivy2/cache -name "ivydata-*.properties" -delete || true 46 | find $HOME/.cache/coursier/v1 -name "ivydata-*.properties" -delete || true 47 | find $HOME/.sbt -name "*.lock" -delete || true 48 | -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/project/SbtHelloJsTask.scala: -------------------------------------------------------------------------------- 1 | package com.hello 2 | 3 | import sbt._ 4 | import sbt.Keys._ 5 | import sbt.File 6 | import com.typesafe.sbt.jse.SbtJsTask 7 | import com.typesafe.sbt.web.SbtWeb 8 | import spray.json.{JsBoolean, JsObject} 9 | 10 | object Import { 11 | 12 | object HelloJsTaskKeys { 13 | 14 | @transient 15 | val hello = TaskKey[Seq[File]]("hello", "Perform JavaScript linting.") 16 | 17 | val compress = SettingKey[Boolean]("hello-compress", "Write to a .min.js instead of a .js.") 18 | val fail = SettingKey[Boolean]("hello-fail", "Cause a problem to be generated for each source file.") 19 | } 20 | 21 | } 22 | 23 | /** 24 | * The sbt plugin plumbing around the JSHint library. 25 | */ 26 | object SbtHelloJsTask extends AutoPlugin { 27 | 28 | override def requires = SbtJsTask 29 | 30 | override def trigger = AllRequirements 31 | 32 | val autoImport = Import 33 | 34 | import SbtWeb.autoImport._ 35 | import WebKeys._ 36 | import SbtJsTask.autoImport.JsTaskKeys._ 37 | import autoImport.HelloJsTaskKeys._ 38 | 39 | val helloJsTaskUnscopedSettings = Seq( 40 | includeFilter := jsFilter.value, 41 | jsOptions := JsObject( 42 | "compress" -> JsBoolean(compress.value), 43 | "fail" -> JsBoolean(fail.value) 44 | ).compactPrint 45 | ) 46 | 47 | override def buildSettings = Project.inTask(hello)( 48 | SbtJsTask.jsTaskSpecificUnscopedBuildSettings ++ Seq( 49 | moduleName := "hello", 50 | shellFile := SbtHelloJsTask.getClass.getClassLoader.getResource("hello-shell.js") 51 | ) 52 | ) 53 | 54 | override def projectSettings = Seq( 55 | compress := false, 56 | fail := false 57 | ) ++ 58 | Project.inTask(hello)( 59 | SbtJsTask.jsTaskSpecificUnscopedProjectSettings ++ 60 | inConfig(Assets)(helloJsTaskUnscopedSettings) ++ 61 | inConfig(TestAssets)(helloJsTaskUnscopedSettings) ++ 62 | Seq( 63 | (Assets / taskMessage) := "Saying hello", 64 | (TestAssets / taskMessage) := "Saying hello test" 65 | 66 | ) 67 | ) ++ SbtJsTask.addJsSourceFileTasks(hello) 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/JavaxEngine.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | 4 | import java.io._ 5 | 6 | import javax.script._ 7 | 8 | import scala.collection.immutable 9 | 10 | class JavaxEngine( 11 | stdArgs: immutable.Seq[String], 12 | engineName: String 13 | ) extends Engine { 14 | 15 | private val engine = new ScriptEngineManager(null).getEngineByName(engineName) 16 | 17 | if (engine == null) throw new Exception(s"Javascript engine '$engineName' not found") 18 | 19 | override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String], 20 | stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = { 21 | 22 | val script = source.getCanonicalFile 23 | 24 | val scriptReader = new FileReader(script) 25 | val stdErrWriter = new LineSinkWriter(stdErrSink) 26 | 27 | val context = { 28 | val c: ScriptContext = new SimpleScriptContext() 29 | c.setReader(new StringReader("")) 30 | c.setWriter(new LineSinkWriter(stdOutSink)) 31 | c.setErrorWriter(stdErrWriter) 32 | // If you create a new ScriptContext object and use it to evaluate scripts, then 33 | // ENGINE_SCOPE of that context has to be associated with a nashorn Global object somehow. 34 | // See https://wiki.openjdk.java.net/display/Nashorn/Nashorn+jsr223+engine+notes 35 | c.setBindings(engine.getContext.getBindings(ScriptContext.ENGINE_SCOPE), ScriptContext.ENGINE_SCOPE) 36 | c.setAttribute("arguments", (stdArgs ++ args).toArray, ScriptContext.ENGINE_SCOPE) 37 | c.setAttribute(ScriptEngine.FILENAME, script.getName, ScriptContext.ENGINE_SCOPE) 38 | c 39 | } 40 | 41 | try { 42 | engine.eval(scriptReader, context) 43 | JsExecutionResult(0) 44 | } catch { 45 | case e: ScriptException => 46 | e.printStackTrace(new PrintWriter(stdErrWriter)) 47 | JsExecutionResult(1) 48 | } 49 | } 50 | 51 | override def isNode: Boolean = false 52 | } 53 | 54 | object JavaxEngine { 55 | 56 | def apply( 57 | stdArgs: immutable.Seq[String] = Nil, 58 | engineName: String = "js" 59 | ): JavaxEngine = new JavaxEngine(stdArgs, engineName) 60 | 61 | } -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/gens.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import java.io.File 4 | import xsbti.Severity 5 | import com.typesafe.sbt.jse.SbtJsTask.JsTaskProtocol.{SourceResultPair, ProblemResultsPair} 6 | import com.typesafe.sbt.web.incremental.{OpResult, OpSuccess, OpFailure} 7 | import com.typesafe.sbt.web.LineBasedProblem 8 | import org.scalacheck.{Arbitrary, Gen} 9 | import org.scalacheck.Arbitrary.arbitrary 10 | 11 | 12 | 13 | object gens { 14 | import Gen._ 15 | 16 | implicit val fileArb: Arbitrary[File] = Arbitrary(fileGen) 17 | implicit val opResultArb: Arbitrary[OpResult] = Arbitrary(opResultGen) 18 | implicit val lineBasedProblemArb: Arbitrary[LineBasedProblem] = Arbitrary(lineBasedProblemGen) 19 | implicit val sourceResultPairArb: Arbitrary[SourceResultPair] = Arbitrary(sourceResultPairGen) 20 | implicit val problemResultsPairArb: Arbitrary[ProblemResultsPair] = Arbitrary(problemResultsPairGen) 21 | 22 | val directoryNameGen: Gen[String] = 23 | for { 24 | numChars <- choose(1, 40) 25 | chars <- listOfN(numChars, alphaNumChar) 26 | } yield chars.map(_.toString).mkString 27 | 28 | val fileGen: Gen[File] = 29 | for { 30 | depth <- choose(0, 20) 31 | directories <- listOfN(depth, directoryNameGen) 32 | } yield new File(directories.mkString(File.separator, File.separator, "")).getCanonicalFile 33 | 34 | val opResultGen: Gen[OpResult] = oneOf(const(OpFailure), resultOf(OpSuccess.apply _)) 35 | 36 | val severityGen: Gen[Severity] = oneOf(Severity.Info, Severity.Warn, Severity.Error) 37 | 38 | val lineBasedProblemGen: Gen[LineBasedProblem] = 39 | for { 40 | message <- arbitrary[String] 41 | severity <- severityGen 42 | lineNumber <- arbitrary[Int] 43 | characterOffset <- arbitrary[Int] 44 | lineContent <- arbitrary[String] 45 | source <- fileGen 46 | } yield new LineBasedProblem(message, severity, lineNumber, characterOffset, lineContent, source) 47 | 48 | val sourceResultPairGen: Gen[SourceResultPair] = 49 | resultOf(SourceResultPair.apply _) 50 | 51 | val problemResultsPairGen: Gen[ProblemResultsPair] = 52 | for { 53 | numResults <- choose(0, 10) 54 | numProblems <- choose(0, 20) 55 | results <- containerOfN[Seq, SourceResultPair](numResults, sourceResultPairGen) 56 | problems <- containerOfN[Seq, LineBasedProblem](numProblems, lineBasedProblemGen) 57 | } yield ProblemResultsPair(results, problems) 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/Trireme.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import java.io._ 4 | import java.util.concurrent.{ForkJoinPool, TimeUnit} 5 | 6 | import scala.collection.immutable 7 | import scala.jdk.CollectionConverters.* 8 | import scala.concurrent.duration._ 9 | import io.apigee.trireme.core._ 10 | import org.mozilla.javascript.RhinoException 11 | 12 | import scala.concurrent.ExecutionException 13 | import scala.util.control.NonFatal 14 | 15 | class Trireme( 16 | stdArgs: immutable.Seq[String], 17 | stdEnvironment: Map[String, String] 18 | ) extends Engine { 19 | 20 | private val AwaitTerminationTimeout = 1.second 21 | 22 | override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String], 23 | stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = { 24 | 25 | val file = source.getCanonicalFile 26 | 27 | val env = (sys.env ++ stdEnvironment ++ environment).asJava 28 | val sandbox = new Sandbox() 29 | sandbox.setAsyncThreadPool(ForkJoinPool.commonPool()) 30 | val nodeEnv = new NodeEnvironment() 31 | nodeEnv.setSandbox(sandbox) 32 | sandbox.setStdin(new ByteArrayInputStream(Array())) 33 | sandbox.setStdout(new LineSinkOutputStream(stdOutSink)) 34 | val stderrOs = new LineSinkOutputStream(stdErrSink) 35 | sandbox.setStderr(stderrOs) 36 | 37 | val script = nodeEnv.createScript(file.getName, file, (stdArgs ++ args).toArray) 38 | script.setEnvironment(env) 39 | 40 | try { 41 | val status = script.execute.get() 42 | if (status.hasCause) { 43 | handleError(status.getCause, stderrOs) 44 | } else { 45 | JsExecutionResult(0) 46 | } 47 | } catch { 48 | case NonFatal(e) => handleError(e, stderrOs) 49 | } finally { 50 | script.close() 51 | nodeEnv.getScriptPool.shutdown() 52 | nodeEnv.getScriptPool.awaitTermination(AwaitTerminationTimeout.toMillis, TimeUnit.MILLISECONDS) 53 | } 54 | } 55 | 56 | private def handleError(error: Throwable, stderrOs: OutputStream): JsExecutionResult = { 57 | error match { 58 | case ee: ExecutionException => 59 | handleError(ee.getCause, stderrOs) 60 | case e: RhinoException => 61 | stderrOs.write(e.getLocalizedMessage.getBytes("UTF-8")) 62 | stderrOs.write(e.getScriptStackTrace.getBytes("UTF-8")) 63 | JsExecutionResult(1) 64 | case t => 65 | t.printStackTrace(new PrintStream(stderrOs)) 66 | JsExecutionResult(1) 67 | } 68 | } 69 | 70 | override def isNode: Boolean = true 71 | } 72 | 73 | object Trireme { 74 | def apply( 75 | stdArgs: immutable.Seq[String] = Nil, 76 | stdEnvironment: Map[String, String] = Map.empty 77 | ): Trireme = new Trireme(stdArgs, stdEnvironment) 78 | } -------------------------------------------------------------------------------- /src/sbt-test/sbt-js-engine/jstask/project/src/main/resources/hello-shell.js: -------------------------------------------------------------------------------- 1 | /*global process, require */ 2 | 3 | /** 4 | * A generic template that reads the files passed in and then writes them back out. 5 | * .js files are expected as input and a "compress" option can be passed in to write 6 | * it out as a .min.js instead. A "problem" option can also be passed which will generate 7 | * a problem object for each file read. 8 | */ 9 | (function () { 10 | 11 | "use strict"; 12 | 13 | var args = process.argv, 14 | fs = require("fs"), 15 | mkdirp = require("mkdirp"), 16 | path = require("path"); 17 | 18 | var SOURCE_FILE_MAPPINGS_ARG = 2; 19 | var TARGET_ARG = 3; 20 | var OPTIONS_ARG = 4; 21 | 22 | var sourceFileMappings = JSON.parse(args[SOURCE_FILE_MAPPINGS_ARG]); 23 | var target = args[TARGET_ARG]; 24 | var options = JSON.parse(args[OPTIONS_ARG]); 25 | 26 | var sourcesToProcess = sourceFileMappings.length; 27 | var results = []; 28 | var problems = []; 29 | 30 | function processingDone() { 31 | if (--sourcesToProcess === 0) { 32 | console.log("\u0010" + JSON.stringify({results: results, problems: problems})); 33 | } 34 | } 35 | 36 | function throwIfErr(e) { 37 | if (e) throw e; 38 | } 39 | 40 | sourceFileMappings.forEach(function (sourceFileMapping) { 41 | 42 | var input = sourceFileMapping[0]; 43 | var outputFile = sourceFileMapping[1].replace(".js", options.compress ? ".min.js" : ".js"); 44 | var output = path.join(target, outputFile); 45 | 46 | fs.readFile(input, "utf8", function (e, contents) { 47 | throwIfErr(e); 48 | 49 | if (options.fail) { 50 | problems.push({ 51 | message: "Whoops", 52 | severity: "error", 53 | lineNumber: 10, 54 | characterOffset: 5, 55 | lineContent: "Fictitious problem", 56 | source: input 57 | }); 58 | results.push({ 59 | source: input, 60 | result: null 61 | }); 62 | 63 | processingDone(); 64 | 65 | } else { 66 | mkdirp(path.dirname(output), function (e) { 67 | throwIfErr(e); 68 | 69 | fs.writeFile(output, contents, "utf8", function (e) { 70 | throwIfErr(e); 71 | 72 | results.push({ 73 | source: input, 74 | result: { 75 | filesRead: [input], 76 | filesWritten: [output] 77 | } 78 | }); 79 | 80 | processingDone(); 81 | }); 82 | }); 83 | } 84 | 85 | }); 86 | }); 87 | })(); -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/Rhino.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | 4 | import java.io._ 5 | import java.net.URI 6 | 7 | import scala.collection.immutable 8 | import org.mozilla.javascript._ 9 | import org.mozilla.javascript.commonjs.module.RequireBuilder 10 | import org.mozilla.javascript.commonjs.module.provider.{SoftCachingModuleScriptProvider, UrlModuleSourceProvider} 11 | import org.mozilla.javascript.tools.shell.Global 12 | 13 | class Rhino( 14 | stdArgs: immutable.Seq[String], 15 | stdModulePaths: immutable.Seq[String] 16 | ) extends Engine { 17 | 18 | override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String], 19 | stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = { 20 | 21 | val script = source.getCanonicalFile 22 | 23 | val requireBuilder = { 24 | import scala.jdk.CollectionConverters.* 25 | val paths = script.getParentFile.toURI +: stdModulePaths.map(new URI(_)) 26 | val sourceProvider = new UrlModuleSourceProvider(paths.asJava, null) 27 | val scriptProvider = new SoftCachingModuleScriptProvider(sourceProvider) 28 | new RequireBuilder().setModuleScriptProvider(scriptProvider) 29 | } 30 | 31 | val ctx = Context.enter() 32 | val stderrOs = new LineSinkOutputStream(stdErrSink) 33 | 34 | try { 35 | 36 | // Create a global object so that we have Rhino shell functions in scope (e.g. load, print, ...) 37 | val global = { 38 | val g = new Global() 39 | g.init(ctx) 40 | g.setIn(new ByteArrayInputStream(Array())) 41 | g.setOut(new PrintStream(new LineSinkOutputStream(stdOutSink))) 42 | g.setErr(new PrintStream(stderrOs)) 43 | g 44 | } 45 | 46 | // Prepare a scope by passing the arguments and adding CommonJS support 47 | val scope = { 48 | val s = ctx.initStandardObjects(global, false) 49 | s.defineProperty("arguments", (stdArgs ++ args).toArray, ScriptableObject.READONLY) 50 | val require = requireBuilder.createRequire(ctx, s) 51 | require.install(s) 52 | s 53 | } 54 | 55 | // Evaluate 56 | val reader = new FileReader(script) 57 | ctx.evaluateReader(scope, reader, script.getName, 0, null) 58 | JsExecutionResult(0) 59 | 60 | } catch { 61 | 62 | case e: RhinoException => 63 | stderrOs.write(e.getLocalizedMessage.getBytes("UTF-8")) 64 | stderrOs.write(e.getScriptStackTrace.getBytes("UTF-8")) 65 | JsExecutionResult(1) 66 | 67 | case t: Exception => 68 | t.printStackTrace(new PrintStream(stderrOs)) 69 | JsExecutionResult(1) 70 | 71 | } finally { 72 | Context.exit() 73 | } 74 | 75 | } 76 | 77 | override def isNode: Boolean = false 78 | } 79 | 80 | object Rhino { 81 | def apply( 82 | stdArgs: immutable.Seq[String] = Nil, 83 | stdModulePaths: immutable.Seq[String] = Nil 84 | ): Rhino = new Rhino(stdArgs, stdModulePaths) 85 | } -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/SbtJsTaskPluginSpec.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import org.specs2.ScalaCheck 4 | import org.specs2.mutable.Specification 5 | import spray.json.JsonParser 6 | import java.io.File 7 | import xsbti.Severity 8 | import com.typesafe.sbt.web.incremental.OpSuccess 9 | 10 | class SbtJsTaskPluginSpec extends Specification with ScalaCheck { 11 | 12 | "the jstask" should { 13 | "Translate json OpResult/Problems properly" in { 14 | val p = JsonParser( s""" 15 | { 16 | "problems": [ 17 | { 18 | "characterOffset": 5, 19 | "lineContent": "a = 1", 20 | "lineNumber": 1, 21 | "message": "Missing semicolon.", 22 | "severity": "error", 23 | "source": "src/main/assets/js/a.js" 24 | } 25 | ], 26 | "results": [ 27 | { 28 | "result": { 29 | "filesRead": [ 30 | "src/main/assets/js/a.js" 31 | ], 32 | "filesWritten": [] 33 | }, 34 | "source": "src/main/assets/js/a.js" 35 | } 36 | ] 37 | } 38 | """) 39 | 40 | import SbtJsTask.JsTaskProtocol.* 41 | val problemResultsPair = p.convertTo[ProblemResultsPair] 42 | problemResultsPair.problems.size must_== 1 43 | problemResultsPair.problems.head.position().offset().get() must_== 5 44 | problemResultsPair.problems.head.position().lineContent() must_== "a = 1" 45 | problemResultsPair.problems.head.position().line().get() must_== 1 46 | problemResultsPair.problems.head.message must_== "Missing semicolon." 47 | problemResultsPair.problems.head.severity must_== Severity.Error 48 | problemResultsPair.problems.head.position().sourceFile().get must_== new File("src/main/assets/js/a.js") 49 | problemResultsPair.results.size must_== 1 50 | val opSuccess = problemResultsPair.results.head.result.asInstanceOf[OpSuccess] 51 | opSuccess.filesRead.size must_== 1 52 | opSuccess.filesWritten.size must_== 0 53 | problemResultsPair.results.head.source must_== new File("src/main/assets/js/a.js") 54 | } 55 | 56 | "Write a ProblemResultsPair as json then read it and recover the original value" in { 57 | import SbtJsTask.JsTaskProtocol.{ProblemResultsPair, problemResultPairFormat} 58 | import gens.* 59 | import helpers.* 60 | 61 | 62 | prop { (doc: ProblemResultsPair) => 63 | val roundTrip = problemResultPairFormat.read(problemResultPairFormat.write(doc)) 64 | 65 | roundTrip.results must containTheSameElementsAs(doc.results, sourceResultPairEquality) 66 | roundTrip.problems must containTheSameElementsAs(doc.problems, lineBasedProblemEquality) 67 | } 68 | } 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sbt-js-engine 2 | ============= 3 | 4 | [![Build Status](https://github.com/sbt/sbt-js-engine/actions/workflows/build-test.yml/badge.svg)](https://github.com/sbt/sbt-js-engine/actions/workflows/build-test.yml) 5 | 6 | This plugin mainly provides support for the authoring of sbt plugins that require js-engine. 7 | 8 | Of particular note is SbtJsTaskPlugin. This plugin provides an abstract base intended to be used for creating 9 | the various types of plugin for sbt-web. At this time the types of plugin are "source file plugins" and "other". 10 | 11 | Source file plugins use the `sources` and `sourceDirectories` sbt keys and process files from there. Plugins of this 12 | type can optionally produce managed resources. 13 | 14 | The other types of plugin are ones that wish to just invoke the js engine and so there are helper functions to do 15 | that. 16 | 17 | Enable this plugin in your project by adding it to your project's `plugins.sbt` file: 18 | 19 | addSbtPlugin("com.github.sbt" % "sbt-js-engine" % "") 20 | 21 | The following options are provided: 22 | 23 | Option | Description 24 | ----------------------------|------------ 25 | command | The filesystem location of the command to execute. Commands such as "node" default to being known to your path. However there path can be supplied here." 26 | engineType | The type of engine to use i.e. CommonNode, Node, PhantomJs, Rhino, Trireme, or AutoDetect. The default is AutoDetect, which uses Node if installed or otherwise falls back to Trireme. 27 | npmPreferSystemInstalledNpm | Prefer detecting and using locally installed NPM when using a local engine that provides Node support. Defaults to true. 28 | npmSubcommand | The subcommand that NPM should use i.e. Install, Update, Ci. Defaults to Update. 29 | 30 | The following sbt code illustrates how the engine type can be set to Node: 31 | 32 | ```scala 33 | JsEngineKeys.engineType := JsEngineKeys.EngineType.Node 34 | ``` 35 | 36 | Alternatively, for `command` and `engineType` you can provide a system property via SBT_OPTS, for example: 37 | 38 | ```bash 39 | export SBT_OPTS="$SBT_OPTS -Dsbt.jse.engineType=Node" 40 | ``` 41 | 42 | and another example: 43 | 44 | ```bash 45 | export SBT_OPTS="$SBT_OPTS -Dsbt.jse.command=/usr/local/bin/node" 46 | ``` 47 | 48 | ## npm 49 | 50 | sbt-js-engine also enhances sbt-web with [npm](https://www.npmjs.org/) functionality. If a `package.json` file 51 | is found in the project's base directory then it will cause npm to run. 52 | 53 | npm extracts its artifacts into the `node_modules` folder of a base directory and makes the contents available to sbt-web plugins as a whole. Note that sbt-js-engines loads the actual source code of npm via a WebJar and invokes an "npm update". Any external npm activity can therefore be performed interchangeably with sbt-js-engine in place. 54 | 55 | > Note that the npm functionality requires JDK 7 when running Trireme given the additional file system support required. If JDK 6 is required then use Node as the engine. 56 | 57 | # Releasing sbt-js-engine 58 | 59 | 1. Tag the release: `git tag -s 1.2.3` 60 | 1. Push tag: `git push upstream 1.2.3` 61 | 1. GitHub action workflow does the rest: https://github.com/sbt/sbt-js-engine/actions/workflows/publish.yml 62 | -------------------------------------------------------------------------------- /src/test/scala/com/typesafe/sbt/jse/engines/TriremeSpec.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import org.specs2.mutable.Specification 4 | import java.io.File 5 | import scala.collection.immutable 6 | 7 | class TriremeSpec extends Specification { 8 | 9 | sequential 10 | 11 | "The Trireme engine" should { 12 | "execute some javascript by passing in a string arg and comparing its return value" in { 13 | val f = new File(classOf[TriremeSpec].getResource("test-node.js").toURI) 14 | val out = new StringBuilder 15 | val err = new StringBuilder 16 | Trireme().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 17 | err.toString.trim must_== "" 18 | out.toString.trim must_== "999" 19 | } 20 | 21 | "execute some javascript by passing in a string arg and comparing its return value expecting an error" in { 22 | val f = new File(classOf[TriremeSpec].getResource("test-rhino.js").toURI) 23 | val out = new StringBuilder 24 | val err = new StringBuilder 25 | Trireme().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 26 | out.toString.trim must_== "" 27 | err.toString.trim must startWith("""ReferenceError: "readFile" is not defined""") 28 | } 29 | } 30 | 31 | private def runSimpleTest() = { 32 | val f = new File(classOf[TriremeSpec].getResource("test-node.js").toURI) 33 | val out = new StringBuilder 34 | val err = new StringBuilder 35 | Trireme().executeJs(f, immutable.Seq("999"), Map.empty, out.append(_), err.append(_)) 36 | err.toString.trim must_== "" 37 | out.toString.trim must_== "999" 38 | } 39 | 40 | "not leak threads" in { 41 | // this test assumes that there are no other trireme tests running concurrently, if there are, the trireme thread 42 | // count will be non 0 43 | runSimpleTest() 44 | 45 | Thread.sleep(5) // locally 1ms is enough, but it seems ci server needs a bit more time 46 | 47 | import scala.jdk.CollectionConverters.* 48 | val triremeThreads = Thread.getAllStackTraces.keySet.asScala 49 | .filter(_.getName.contains("Trireme")) 50 | 51 | ("trireme threads: " + triremeThreads) <==> (triremeThreads.size === 0) 52 | ok 53 | } 54 | 55 | "not leak file descriptors" in { 56 | import java.lang.management._ 57 | val os = ManagementFactory.getOperatingSystemMXBean 58 | try { 59 | // To get the open file descriptor count, need to check if it exists 60 | os.getClass.getMethod("getOpenFileDescriptorCount") 61 | 62 | // brew a little first 63 | runSimpleTest() 64 | runSimpleTest() 65 | runSimpleTest() 66 | runSimpleTest() 67 | 68 | val openFds = UnixGetOpenFileDescriptors.getCount() 69 | runSimpleTest() 70 | 71 | UnixGetOpenFileDescriptors.getCount() must_== openFds 72 | } catch { 73 | case _: NoSuchMethodException => 74 | println("Skipping file descriptor leak test because OS mbean doesn't have getOpenFileDescriptorCount") 75 | ok 76 | } 77 | } 78 | } 79 | 80 | // This is in a separate object so that it only gets loaded when we call it, avoiding class not found 81 | // in Windows 82 | object UnixGetOpenFileDescriptors { 83 | import java.lang.management.ManagementFactory 84 | import com.sun.management.UnixOperatingSystemMXBean 85 | 86 | private val os = ManagementFactory.getOperatingSystemMXBean 87 | def getCount(): Long = { 88 | os.asInstanceOf[UnixOperatingSystemMXBean] 89 | .getMaxFileDescriptorCount 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/engines/LocalEngine.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.engines 2 | 3 | import java.io.File 4 | 5 | import scala.jdk.CollectionConverters.* 6 | import scala.collection.immutable 7 | import scala.sys.process.{Process, ProcessLogger} 8 | 9 | class LocalEngine(val stdArgs: immutable.Seq[String], val stdEnvironment: Map[String, String], override val isNode: Boolean) extends Engine { 10 | 11 | override def executeJs(source: File, args: immutable.Seq[String], environment: Map[String, String], 12 | stdOutSink: String => Unit, stdErrSink: String => Unit): JsExecutionResult = { 13 | 14 | val allArgs = (stdArgs :+ source.getCanonicalPath) ++ args 15 | val allEnvironment = stdEnvironment ++ environment 16 | 17 | 18 | val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava) 19 | pb.environment().putAll(allEnvironment.asJava) 20 | JsExecutionResult(Process(pb).!(ProcessLogger(stdOutSink, stdErrSink))) 21 | } 22 | } 23 | 24 | 25 | /** 26 | * Local engine utilities. 27 | */ 28 | object LocalEngine { 29 | 30 | def path(path: Option[File], command: String): String = path.fold(command)(_.getCanonicalPath) 31 | 32 | val nodePathDelim: String = if (System.getProperty("os.name").toLowerCase.contains("win")) ";" else ":" 33 | 34 | def nodePathEnv(modulePaths: immutable.Seq[String]): Map[String, String] = { 35 | val nodePath = modulePaths.mkString(nodePathDelim) 36 | val newNodePath = Option(System.getenv("NODE_PATH")).fold(nodePath)(_ + nodePathDelim + nodePath) 37 | if (newNodePath.isEmpty) Map.empty[String, String] else Map("NODE_PATH" -> newNodePath) 38 | } 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 59 | } 60 | 61 | /** 62 | * Used to manage a local instance of Node.js with CommonJs support. common-node is assumed to be on the path. 63 | */ 64 | object CommonNode { 65 | 66 | import LocalEngine._ 67 | 68 | def apply(command: Option[File] = None, stdArgs: immutable.Seq[String] = Nil, stdEnvironment: Map[String, String] = Map.empty): LocalEngine = { 69 | val args = immutable.Seq(path(command, "common-node")) ++ stdArgs 70 | new LocalEngine(args, stdEnvironment, true) 71 | } 72 | } 73 | 74 | /** 75 | * Used to manage a local instance of Node.js. Node is assumed to be on the path. 76 | */ 77 | object Node { 78 | 79 | import LocalEngine._ 80 | 81 | def apply(command: Option[File] = None, stdArgs: immutable.Seq[String] = Nil, stdEnvironment: Map[String, String] = Map.empty): LocalEngine = { 82 | val args = immutable.Seq(path(command, "node")) ++ stdArgs 83 | new LocalEngine(args, stdEnvironment, true) 84 | } 85 | } 86 | 87 | /** 88 | * Used to manage a local instance of PhantomJS. PhantomJS is assumed to be on the path. 89 | */ 90 | object PhantomJs { 91 | 92 | import LocalEngine._ 93 | 94 | def apply(command: Option[File] = None, stdArgs: immutable.Seq[String] = Nil, stdEnvironment: Map[String, String] = Map.empty): LocalEngine = { 95 | val args = immutable.Seq(path(command, "phantomjs")) ++ stdArgs 96 | new LocalEngine(args, stdEnvironment, false) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/npm/Npm.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse.npm 2 | 3 | 4 | import java.io.File 5 | 6 | import com.typesafe.sbt.jse.engines.{Engine, JsExecutionResult, LocalEngine} 7 | 8 | import scala.collection.compat.* 9 | import scala.collection.immutable 10 | import scala.collection.JavaConverters.* 11 | import scala.collection.mutable.ListBuffer 12 | import scala.sys.process.{Process, ProcessLogger} 13 | import scala.util.Try 14 | 15 | /** 16 | * A JVM class for performing NPM commands. Requires a JS engine to use. 17 | */ 18 | class Npm(engine: Engine, npmFile: Option[File] = None, preferSystemNpm: Boolean = true, verbose: Boolean = false) { 19 | 20 | @deprecated("Use one of the other non-deprecated constructors instead", "1.3.0") 21 | def this(engine: Engine, npmFile: File) = this(engine, Some(npmFile)) 22 | 23 | @deprecated("Use one of the other non-deprecated constructors instead", "1.3.0") 24 | def this(engine: Engine, npmFile: File, verbose: Boolean) = this(engine, Some(npmFile), verbose) 25 | 26 | /** 27 | * https://docs.npmjs.com/cli/commands/npm-install 28 | */ 29 | def install(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = { 30 | cmd("install", global, names, outSink, errSink) 31 | } 32 | 33 | /** 34 | * https://docs.npmjs.com/cli/commands/npm-update 35 | */ 36 | def update(global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = { 37 | cmd("update", global, names, outSink, errSink) 38 | } 39 | 40 | /** 41 | * https://docs.npmjs.com/cli/commands/npm-ci 42 | */ 43 | def ci(names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = { 44 | cmd("ci", false, names, outSink, errSink) // ci subcommand does not support -g (global) flag 45 | } 46 | 47 | private def cmd(cmd: String, global: Boolean = false, names: Seq[String] = Nil, outSink: String => Unit, errSink: String => Unit): JsExecutionResult = { 48 | val args = ListBuffer[String]() 49 | args += cmd 50 | if (global) args += "-g" 51 | if (verbose) args += "--verbose" 52 | args ++= names 53 | invokeNpm(args, outSink, errSink) 54 | } 55 | 56 | private def detectNpm(command: String): Option[String] = { 57 | val npmExists = Try(Process(s"$command --version").!!).isSuccess 58 | if (!npmExists) { 59 | println("!!!") 60 | println(s"Warning: npm detection failed. Tried the command: $command") 61 | println("!!!") 62 | None 63 | } else { 64 | Some(command) 65 | } 66 | } 67 | 68 | private def invokeNpm(args: ListBuffer[String], outSink: String => Unit, errSink: String => Unit): JsExecutionResult = { 69 | if (!engine.isNode) { 70 | throw new IllegalStateException("node not found: a Node.js installation is required to run npm.") 71 | } 72 | 73 | def executeJsNpm(): JsExecutionResult = 74 | 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) 75 | 76 | engine match { 77 | case localEngine: LocalEngine if preferSystemNpm => 78 | // The first argument always is the command of the js engine, e.g. either just "node", "phantomjs,.. or a path like "/usr/bin/node" 79 | // So, if the command is a path, we first try to detect if there is a npm command available in the same folder 80 | val localEngineCmd = new File(localEngine.stdArgs.head) 81 | val localNpmCmd = if (localEngineCmd.getParent() == null) { 82 | // Pretty sure the command was not a path but just something like "node" 83 | // Therefore we assume the npm command is on the operating system path, just like the js engine command 84 | detectNpm("npm") 85 | } else { 86 | // Looks like the command was a valid path, so let's see if we can detect a npm command within the same folder 87 | // If we can't, try to fallback to a npm command that is on the operating system path 88 | val cmdPath = new File(localEngineCmd.getParentFile, "npm").getCanonicalPath 89 | detectNpm(cmdPath).orElse(detectNpm("npm")) 90 | } 91 | localNpmCmd match { 92 | case Some(cmd) => 93 | val allArgs = immutable.Seq(cmd) ++ args 94 | val pb = new ProcessBuilder(LocalEngine.prepareArgs(allArgs).asJava) 95 | pb.environment().putAll(localEngine.stdEnvironment.asJava) 96 | JsExecutionResult(Process(pb).!(ProcessLogger(outSink, errSink))) 97 | case None => 98 | println("!!!") 99 | 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.") 100 | println("!!!") 101 | executeJsNpm() 102 | } 103 | case _ => // e.g. Trireme provides node, but is not a local install and does not provide npm, therefore fallback using the webjar npm 104 | executeJsNpm() 105 | } 106 | } 107 | 108 | } 109 | 110 | 111 | import org.webjars.WebJarExtractor 112 | 113 | object NpmLoader { 114 | /** 115 | * Extract the NPM WebJar to disk and return its main entry point. 116 | * @param to The directory to extract to. 117 | * @param classLoader The classloader that should be used to locate the Node related WebJars. 118 | * @return The main JavaScript entry point into NPM. 119 | */ 120 | def load(to: File, classLoader: ClassLoader): File = { 121 | val extractor = new WebJarExtractor(classLoader) 122 | extractor.extractAllNodeModulesTo(to) 123 | new File(to, "npm" + File.separator + "lib" + File.separator + "npm.js") 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/SbtJsEngine.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import com.typesafe.sbt.PluginCompat.{FileRef, cacheLevel} 4 | import sbt.* 5 | import sbt.Keys.* 6 | 7 | import scala.collection.immutable 8 | import com.typesafe.sbt.jse.engines.* 9 | import com.typesafe.sbt.jse.npm.Npm 10 | import com.typesafe.sbt.web.SbtWeb 11 | 12 | import scala.concurrent.duration.* 13 | import scala.sys.process.Process 14 | import scala.util.Try 15 | 16 | object JsEngineImport { 17 | 18 | object JsEngineKeys { 19 | 20 | object EngineType extends Enumeration { 21 | val CommonNode, Node, PhantomJs, Javax, Rhino, Trireme, 22 | 23 | /** 24 | * Auto detect the best available engine to use for most common tasks - this will currently select node if 25 | * available, otherwise it will fall back to trireme 26 | */ 27 | AutoDetect = Value 28 | } 29 | 30 | object NpmSubcommand extends Enumeration { 31 | val Install, Update, Ci = Value 32 | } 33 | 34 | val command = SettingKey[Option[File]]("jse-command", "An optional path to the command used to invoke the engine.") 35 | val engineType = SettingKey[EngineType.Value]("jse-engine-type", "The type of engine to use.") 36 | @deprecated("No longer used", "1.3.0") 37 | val parallelism = SettingKey[Int]("jse-parallelism", "The number of parallel tasks for the JavaScript engine. Defaults to the # of available processors + 1 to keep things busy.") 38 | @deprecated("No longer used", "1.3.0") 39 | val npmTimeout = SettingKey[FiniteDuration]("jse-npm-timeout", "The maximum number amount of time for npm to do its thing.") 40 | @cacheLevel(include = Array.empty) 41 | val npmNodeModules = TaskKey[Seq[File]]("jse-npm-node-modules", "Node module files generated by NPM.") 42 | 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") 43 | val npmSubcommand = SettingKey[NpmSubcommand.Value]("jse-npm-subcommand", "The subcommand to use in NPM: install, update or ci") 44 | } 45 | 46 | } 47 | 48 | /** 49 | * Declares the main parts of a WebDriver based plugin for sbt. 50 | */ 51 | object SbtJsEngine extends AutoPlugin { 52 | 53 | override def requires: Plugins = SbtWeb 54 | 55 | override def trigger: PluginTrigger = AllRequirements 56 | 57 | val autoImport: JsEngineImport.type = JsEngineImport 58 | 59 | import SbtWeb.autoImport.* 60 | import WebKeys.* 61 | import autoImport.* 62 | import JsEngineKeys.* 63 | 64 | /** 65 | * Convert an engine type enum to an engine. 66 | */ 67 | def engineTypeToEngine(engineType: EngineType.Value, command: Option[File], env: Map[String, String]): Engine = { 68 | engineType match { 69 | case EngineType.CommonNode => CommonNode(command, stdEnvironment = env) 70 | case EngineType.Node => Node(command, stdEnvironment = env) 71 | case EngineType.PhantomJs => PhantomJs(command) 72 | case EngineType.Javax => JavaxEngine() 73 | case EngineType.Rhino => Rhino() 74 | case EngineType.Trireme => Trireme(stdEnvironment = env) 75 | case EngineType.AutoDetect => if (autoDetectNode(command)) { 76 | Node(command, stdEnvironment = env) 77 | } else { 78 | Trireme(stdEnvironment = env) 79 | } 80 | } 81 | } 82 | 83 | private val NodeModules = "node_modules" 84 | private val PackageJson = "package.json" 85 | 86 | 87 | private def autoDetectNode(command: Option[File]): Boolean = { 88 | val nodeExists = Try(Process(s"${LocalEngine.path(command, "node")} --version").!!).isSuccess 89 | if (!nodeExists) { 90 | println("!!!") 91 | 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.") 92 | println("!!!") 93 | } 94 | nodeExists 95 | } 96 | 97 | private val jsEngineUnscopedSettings: Seq[Setting[?]] = Seq( 98 | npmNodeModules := Def.task { 99 | val npmDirectory = baseDirectory.value / NodeModules 100 | val npmPackageJson = baseDirectory.value / PackageJson 101 | val cacheDirectory = streams.value.cacheDirectory / "npm" 102 | val webJarsNodeModulesPath = (Plugin / webJarsNodeModulesDirectory).value.getCanonicalPath 103 | val nodePathEnv = LocalEngine.nodePathEnv(immutable.Seq(webJarsNodeModulesPath)) 104 | val engine = engineTypeToEngine(engineType.value, command.value, nodePathEnv) 105 | val nodeModulesDirectory = (Plugin / webJarsNodeModulesDirectory).value 106 | val logger = streams.value.log 107 | val baseDir = baseDirectory.value 108 | val preferSystemInstalledNpm = npmPreferSystemInstalledNpm.value 109 | val subcommand = npmSubcommand.value 110 | 111 | val runUpdate = FileFunction.cached(cacheDirectory, FilesInfo.hash) { _ => 112 | if (npmPackageJson.exists) { 113 | val npm = new Npm(engine, Some(nodeModulesDirectory / "npm" / "lib" / "npm.js"), preferSystemNpm = preferSystemInstalledNpm) 114 | val result = subcommand match { 115 | case NpmSubcommand.Install => npm.install(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_)) 116 | case NpmSubcommand.Update => npm.update(global = false, Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_)) 117 | case NpmSubcommand.Ci => npm.ci(Seq("--prefix", baseDir.getPath), logger.info(_), logger.error(_)) 118 | } 119 | if (result.exitValue != 0) { 120 | sys.error("Problems with NPM resolution. Aborting build.") 121 | } 122 | npmDirectory.**(AllPassFilter).get().toSet 123 | } else { 124 | IO.delete(npmDirectory) 125 | Set.empty 126 | } 127 | } 128 | runUpdate(Set(npmPackageJson)).toSeq 129 | }.dependsOn(Plugin / webJarsNodeModules).value, 130 | 131 | nodeModuleGenerators += npmNodeModules.taskValue, 132 | nodeModuleDirectories += baseDirectory.value / NodeModules 133 | ) 134 | 135 | private val defaultEngineType = EngineType.AutoDetect 136 | 137 | override def projectSettings: Seq[Setting[?]] = Seq( 138 | engineType := sys.props.get("sbt.jse.engineType").fold(defaultEngineType)(engineTypeStr => 139 | Try(EngineType.withName(engineTypeStr)).getOrElse { 140 | println(s"Unknown engine type $engineTypeStr for sbt.jse.engineType. Resorting back to the default of $defaultEngineType.") 141 | defaultEngineType 142 | }), 143 | command := sys.props.get("sbt.jse.command").map(file), 144 | npmPreferSystemInstalledNpm := true, 145 | npmSubcommand := NpmSubcommand.Update, 146 | 147 | ) ++ inConfig(Assets)(jsEngineUnscopedSettings) ++ inConfig(TestAssets)(jsEngineUnscopedSettings) 148 | 149 | } 150 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Typesafe Project & Developer Guidelines 2 | 3 | These guidelines are meant to be a living document that should be changed and adapted as needed. We encourage changes that makes it easier to achieve our goals in an efficient way. 4 | 5 | These guidelines mainly applies to Typesafe’s “mature” projects - not necessarily to projects of the type ‘collection of scripts’ etc. 6 | 7 | ## General Workflow 8 | 9 | This is the process for committing code into master. There are of course exceptions to these rules, for example minor changes to comments and documentation, fixing a broken build etc. 10 | 11 | 1. Make sure you have signed the [Typesafe CLA](http://www.typesafe.com/contribute/cla), if not, sign it online. 12 | 2. Before starting to work on a feature or a fix, you have to make sure that: 13 | 1. There is a ticket for your work in the project's issue tracker. If not, create it first. 14 | 2. The ticket has been scheduled for the current milestone. 15 | 3. The ticket is estimated by the team. 16 | 4. The ticket have been discussed and prioritized by the team. 17 | 3. You should always perform your work in a Git feature branch. The branch should be given a descriptive name that explains its intent. Some teams also like adding the ticket number and/or the [GitHub](http://github.com) user ID to the branch name, these details is up to each of the individual teams. 18 | 4. When the feature or fix is completed you should open a [Pull Request](https://help.github.com/articles/using-pull-requests) on GitHub. 19 | 5. The Pull Request should be reviewed by other maintainers (as many as feasible/practical). Note that the maintainers can consist of outside contributors, both within and outside Typesafe. Outside contributors (for example from EPFL or independent committers) are encouraged to participate in the review process, it is not a closed process. 20 | 6. After the review you should fix the issues as needed (pushing a new commit for new review etc.), iterating until the reviewers give their thumbs up. 21 | 7. Once the code has passed review the Pull Request can be merged into the master branch. 22 | 23 | ## Pull Request Requirements 24 | 25 | For a Pull Request to be considered at all it has to meet these requirements: 26 | 27 | 1. Live up to the current code standard: 28 | - Not violate [DRY](http://programmer.97things.oreilly.com/wiki/index.php/Don%27t_Repeat_Yourself). 29 | - [Boy Scout Rule](http://programmer.97things.oreilly.com/wiki/index.php/The_Boy_Scout_Rule) needs to have been applied. 30 | 2. Regardless if the code introduces new features or fixes bugs or regressions, it must have comprehensive tests. 31 | 3. The code must be well documented in the Typesafe's standard documentation format (see the ‘Documentation’ section below). 32 | 4. Copyright: 33 | All Typesafe projects must include Typesafe copyright notices. Each project can choose between one of two approaches: 34 | 1. All source files in the project must have a Typesafe copyright notice in the file header. 35 | 2. The Notices file for the project includes the Typesafe copyright notice and no other files contain copyright notices. See http://www.apache.org/legal/src-headers.html for instructions for managing this approach for copyrights. 36 | 37 | Other guidelines to follow for copyright notices: 38 | - Use a form of ``Copyright (C) 2011-2014 Typesafe Inc. ``, where the start year is when the project or file was first created and the end year is the last time the project or file was modified. 39 | - Never delete or change existing copyright notices, just add additional info. 40 | - Do not use ``@author`` tags since it does not encourage [Collective Code Ownership](http://www.extremeprogramming.org/rules/collective.html). However, each project should make sure that the contributors gets the credit they deserve—in a text file or page on the project website and in the release notes etc. 41 | 42 | If these requirements are not met then the code should **not** be merged into master, or even reviewed - regardless of how good or important it is. No exceptions. 43 | 44 | ## Continuous Integration 45 | 46 | Each project should be configured to use a continuous integration (CI) tool (i.e. a build server ala Jenkins). Typesafe has a Jenkins server farm that can be used. The CI tool should, on each push to master, build the **full** distribution and run **all** tests, and if something fails it should email out a notification with the failure report to the committer and the core team. The CI tool should also be used in conjunction with Typesafe’s Pull Request Validator (discussed below). 47 | 48 | ## Documentation 49 | 50 | All documentation should be generated using the sbt-site-plugin, *or* publish artifacts to a repository that can be consumed by the typesafe stack. 51 | 52 | All documentation must abide by the following maxims: 53 | 54 | - Example code should be run as part of an automated test suite. 55 | - Version should be **programmatically** specifiable to the build. 56 | - Generation should be **completely automated** and available for scripting. 57 | - Artifacts that must be included in the Typesafe Stack should be published to a maven “documentation” repository as documentation artifacts. 58 | 59 | All documentation is preferred to be in Typesafe's standard documentation format [reStructuredText](http://doc.akka.io/docs/akka/snapshot/dev/documentation.html) compiled using Typesafe's customized [Sphinx](http://sphinx.pocoo.org/) based documentation generation system, which among other things allows all code in the documentation to be externalized into compiled files and imported into the documentation. 60 | 61 | For more info, or for a starting point for new projects, look at the [Typesafe Documentation Template project](https://github.com/typesafehub/doc-template) 62 | 63 | For larger projects that have invested a lot of time and resources into their current documentation and samples scheme (like for example Play), it is understandable that it will take some time to migrate to this new model. In these cases someone from the project needs to take the responsibility of manual QA and verifier for the documentation and samples. 64 | 65 | ## External Dependencies 66 | 67 | All the external runtime dependencies for the project, including transitive dependencies, must have an open source license that is equal to, or compatible with, [Apache 2](http://www.apache.org/licenses/LICENSE-2.0). 68 | 69 | This must be ensured by manually verifying the license for all the dependencies for the project: 70 | 71 | 1. Whenever a committer to the project changes a version of a dependency (including Scala) in the build file. 72 | 2. Whenever a committer to the project adds a new dependency. 73 | 3. Whenever a new release is cut (public or private for a customer). 74 | 75 | Which licenses that are compatible with Apache 2 are defined in [this doc](http://www.apache.org/legal/3party.html#category-a), where you can see that the licenses that are listed under ``Category A`` automatically compatible with Apache 2, while the ones listed under ``Category B`` needs additional action: 76 | > “Each license in this category requires some degree of [reciprocity](http://www.apache.org/legal/3party.html#define-reciprocal); therefore, additional action must be taken in order to minimize the chance that a user of an Apache product will create a derivative work of a reciprocally-licensed portion of an Apache product without being aware of the applicable requirements.” 77 | 78 | Each project must also create and maintain a list of all dependencies and their licenses, including all their transitive dependencies. This can be done in either in the documentation or in the build file next to each dependency. 79 | 80 | ## Work In Progress 81 | 82 | It is ok to work on a public feature branch in the GitHub repository. Something that can sometimes be useful for early feedback etc. If so then it is preferable to name the branch accordingly. This can be done by either prefix the name with ``wip-`` as in ‘Work In Progress’, or use hierarchical names like ``wip/..``, ``feature/..`` or ``topic/..``. Either way is fine as long as it is clear that it is work in progress and not ready for merge. This work can temporarily have a lower standard. However, to be merged into master it will have to go through the regular process outlined above, with Pull Request, review etc.. 83 | 84 | Also, to facilitate both well-formed commits and working together, the ``wip`` and ``feature``/``topic`` identifiers also have special meaning. Any branch labelled with ``wip`` is considered “git-unstable” and may be rebased and have its history rewritten. Any branch with ``feature``/``topic`` in the name is considered “stable” enough for others to depend on when a group is working on a feature. 85 | 86 | ## Creating Commits And Writing Commit Messages 87 | 88 | Follow these guidelines when creating public commits and writing commit messages. 89 | 90 | 1. If your work spans multiple local commits (for example; if you do safe point commits while working in a feature branch or work in a branch for long time doing merges/rebases etc.) then please do not commit it all but rewrite the history by squashing the commits into a single big commit which you write a good commit message for (like discussed in the following sections). For more info read this article: [Git Workflow](http://sandofsky.com/blog/git-workflow.html). Every commit should be able to be used in isolation, cherry picked etc. 91 | 2. First line should be a descriptive sentence what the commit is doing. It should be possible to fully understand what the commit does by just reading this single line. It is **not ok** to only list the ticket number, type "minor fix" or similar. Include reference to ticket number, prefixed with #, at the end of the first line. If the commit is a small fix, then you are done. If not, go to 3. 92 | 3. Following the single line description should be a blank line followed by an enumerated list with the details of the commit. 93 | 4. Add keywords for your commit (depending on the degree of automation we reach, the list may change over time): 94 | * ``Review by @gituser`` - if you want to notify someone on the team. The others can, and are encouraged to participate. 95 | * ``Fix/Fixing/Fixes/Close/Closing/Refs #ticket`` - if you want to mark the ticket as fixed in the issue tracker (Assembla understands this). 96 | * ``backport to _branch name_`` - if the fix needs to be cherry-picked to another branch (like 2.9.x, 2.10.x, etc) 97 | 98 | Example: 99 | 100 | Adding monadic API to Future. Fixes #2731 101 | 102 | * Details 1 103 | * Details 2 104 | * Details 3 105 | -------------------------------------------------------------------------------- /src/main/scala/com/typesafe/sbt/jse/SbtJsTask.scala: -------------------------------------------------------------------------------- 1 | package com.typesafe.sbt.jse 2 | 3 | import java.util.concurrent.CopyOnWriteArrayList 4 | import sbt.{Configuration, Def, *} 5 | import sbt.Keys.* 6 | import com.typesafe.sbt.web.incremental.OpInputHasher 7 | import spray.json.* 8 | import com.typesafe.sbt.web.* 9 | import xsbti.{FileConverter, Problem, Severity} 10 | import com.typesafe.sbt.web.incremental.OpResult 11 | import com.typesafe.sbt.web.incremental.OpFailure 12 | import com.typesafe.sbt.web.incremental.OpInputHash 13 | import com.typesafe.sbt.web 14 | 15 | import scala.collection.immutable 16 | import com.typesafe.sbt.jse.engines.{Engine, LocalEngine} 17 | import com.typesafe.sbt.web.incremental 18 | import com.typesafe.sbt.web.CompileProblems 19 | import com.typesafe.sbt.web.incremental.OpSuccess 20 | import com.typesafe.sbt.PluginCompat.* 21 | import sbinary.{Format, Input, Output} 22 | 23 | import scala.concurrent.duration.* 24 | import scala.collection.compat.* 25 | 26 | 27 | object JsTaskImport { 28 | 29 | object JsTaskKeys { 30 | 31 | val fileInputHasher = TaskKey[OpInputHasher[File]]("jstask-file-input-hasher", "A function that hashes a given file.") 32 | @transient 33 | val jsOptions = TaskKey[String]("jstask-js-options", "The JSON options to be passed to the task.") 34 | val taskMessage = SettingKey[String]("jstask-message", "The message to output for a task") 35 | val shellFile = SettingKey[URL]("jstask-shell-url", "The url of the file to perform a given task.") 36 | val shellSource = TaskKey[File]("jstask-shell-source", "The target location of the js shell script to use.") 37 | @deprecated("Timeouts are no longer used", "1.3.0") 38 | val timeoutPerSource = SettingKey[FiniteDuration]("jstask-timeout-per-source", "The maximum amount of time to wait per source file processed by the JS task.") 39 | val sourceDependencies = SettingKey[Seq[TaskKey[Seq[File]]]]("jstask-source-dependencies", "Source dependencies between source file tasks.") 40 | } 41 | 42 | } 43 | 44 | /** 45 | * The commonality of JS task execution oriented plugins is captured by this class. 46 | */ 47 | object SbtJsTask extends AutoPlugin { 48 | 49 | override def requires: Plugins = SbtJsEngine 50 | 51 | override def trigger: PluginTrigger = AllRequirements 52 | 53 | val autoImport: JsTaskImport.type = JsTaskImport 54 | 55 | import SbtWeb.autoImport.* 56 | import WebKeys.* 57 | import SbtJsEngine.autoImport.* 58 | import JsEngineKeys.* 59 | import autoImport.* 60 | import JsTaskKeys.* 61 | 62 | val jsTaskSpecificUnscopedConfigSettings = Seq( 63 | fileInputHasher := uncached{ 64 | val options = jsOptions.value 65 | OpInputHasher[File](f => OpInputHash.hashString(f.getAbsolutePath + "|" + options)) 66 | }, 67 | resourceManaged := target.value / moduleName.value 68 | ) 69 | 70 | val jsTaskSpecificUnscopedProjectSettings: Seq[Setting[?]] = 71 | inConfig(Assets)(jsTaskSpecificUnscopedConfigSettings) ++ 72 | inConfig(TestAssets)(jsTaskSpecificUnscopedConfigSettings) 73 | 74 | val jsTaskSpecificUnscopedBuildSettings: Seq[Setting[?]] = 75 | Seq( 76 | shellSource := { 77 | SbtWeb.copyResourceTo( 78 | (Plugin / target).value / moduleName.value, 79 | shellFile.value, 80 | streams.value.cacheDirectory / "copy-resource" 81 | ) 82 | } 83 | ) 84 | 85 | @deprecated("Add jsTaskSpecificUnscopedProjectSettings to AutoPlugin.projectSettings and jsTaskSpecificUnscopedBuildSettings to AutoPlugin.buildSettings", "1.2.0") 86 | val jsTaskSpecificUnscopedSettings: Seq[Setting[?]] = jsTaskSpecificUnscopedProjectSettings ++ jsTaskSpecificUnscopedBuildSettings 87 | 88 | @scala.annotation.nowarn("cat=deprecation") 89 | override def projectSettings: Seq[Setting[?]] = Seq( 90 | jsOptions := "{}", 91 | timeoutPerSource := 2.hours // when removing this line also remove @nowarn above 92 | ) 93 | 94 | 95 | /** 96 | * Thrown when there is an unexpected problem to do with the task's execution. 97 | */ 98 | class JsTaskFailure(m: String) extends RuntimeException(m) 99 | 100 | /** 101 | * For automatic transformation of Json structures. 102 | */ 103 | object JsTaskProtocol extends DefaultJsonProtocol { 104 | 105 | implicit object FileFormat extends JsonFormat[File] { 106 | def write(f: File): JsValue = JsString(f.getCanonicalPath) 107 | 108 | def read(value: JsValue): sbt.File = value match { 109 | case s: JsString => new File(s.convertTo[String]) 110 | case x => deserializationError(s"String expected for a file, instead got $x") 111 | } 112 | } 113 | 114 | implicit val opSuccessFormat: JsonFormat[OpSuccess] = jsonFormat2(OpSuccess.apply) 115 | 116 | implicit object LineBasedProblemFormat extends JsonFormat[LineBasedProblem] { 117 | def write(p: LineBasedProblem): JsObject = JsObject( 118 | "message" -> JsString(p.message), 119 | "severity" -> { 120 | p.severity match { 121 | case Severity.Info => JsString("info") 122 | case Severity.Warn => JsString("warn") 123 | case Severity.Error => JsString("error") 124 | } 125 | }, 126 | "lineNumber" -> JsNumber(p.position.line.get), 127 | "characterOffset" -> JsNumber(p.position.offset.get), 128 | "lineContent" -> JsString(p.position.lineContent), 129 | "source" -> FileFormat.write(p.position.sourceFile.get) 130 | ) 131 | 132 | def read(value: JsValue): LineBasedProblem = value match { 133 | case o: JsObject => new LineBasedProblem( 134 | o.fields.get("message").fold("unknown message")(_.convertTo[String]), 135 | o.fields.get("severity").fold(Severity.Error) { 136 | case JsString("info") => Severity.Info 137 | case JsString("warn") => Severity.Warn 138 | case _ => Severity.Error 139 | }, 140 | o.fields.get("lineNumber").fold(0)(_.convertTo[Int]), 141 | o.fields.get("characterOffset").fold(0)(_.convertTo[Int]), 142 | o.fields.get("lineContent").fold("unknown line content")(_.convertTo[String]), 143 | o.fields.get("source").fold(file(""))(_.convertTo[File]) 144 | ) 145 | case x => deserializationError(s"Object expected for the problem, instead got $x") 146 | } 147 | 148 | } 149 | 150 | implicit object OpResultFormat extends JsonFormat[OpResult] { 151 | 152 | def write(r: OpResult): JsValue = r match { 153 | case OpFailure => JsNull 154 | case s: OpSuccess => opSuccessFormat.write(s) 155 | } 156 | 157 | def read(value: JsValue): OpResult = value match { 158 | case o: JsObject => opSuccessFormat.read(o) 159 | case JsNull => OpFailure 160 | case x => deserializationError(s"Object expected for the op result, instead got $x") 161 | } 162 | } 163 | 164 | case class ProblemResultsPair(results: Seq[SourceResultPair], problems: Seq[LineBasedProblem]) 165 | 166 | case class SourceResultPair(result: OpResult, source: File) 167 | 168 | implicit val sourceResultPairFormat: JsonFormat[SourceResultPair] = jsonFormat2(SourceResultPair.apply) 169 | implicit val problemResultPairFormat: JsonFormat[ProblemResultsPair] = jsonFormat2(ProblemResultsPair.apply) 170 | } 171 | 172 | // Used to signal when the script is sending back structured JSON data 173 | private val JsonEscapeChar: Char = 0x10 174 | 175 | private type FileOpResultMappings = Map[File, OpResult] 176 | 177 | private def FileOpResultMappings(s: (File, OpResult)*): FileOpResultMappings = Map(s *) 178 | 179 | 180 | private def executeJsOnEngine(engine: Engine, shellSource: File, args: Seq[String], 181 | stderrSink: String => Unit, stdoutSink: String => Unit): Seq[JsValue] = { 182 | 183 | val results = new CopyOnWriteArrayList[JsValue]() 184 | 185 | val result = engine.executeJs( 186 | shellSource, 187 | args.to(immutable.Seq), 188 | Map.empty, 189 | line => { 190 | // Extract structured JSON data out before forwarding to the logger 191 | if (line.indexOf(JsonEscapeChar) == -1) { 192 | stdoutSink(line) 193 | } else { 194 | val (out, json) = line.span(_ != JsonEscapeChar) 195 | if (out.nonEmpty) { 196 | stdoutSink(out) 197 | } 198 | results.add(JsonParser(json.drop(1))) 199 | } 200 | }, 201 | stderrSink 202 | ) 203 | 204 | if (result.exitValue != 0) { 205 | throw new JsTaskFailure("") 206 | } 207 | 208 | import scala.jdk.CollectionConverters.* 209 | results.asScala.toList 210 | } 211 | 212 | private def executeSourceFilesJs( 213 | engine: Engine, 214 | shellSource: File, 215 | sourceFileMappings: Seq[PathMapping], 216 | target: File, 217 | options: String, 218 | stderrSink: String => Unit, 219 | stdoutSink: String => Unit, 220 | conv: FileConverter 221 | ): (FileOpResultMappings, Seq[Problem]) = { 222 | 223 | implicit val fc: FileConverter = conv 224 | 225 | val args = immutable.Seq( 226 | JsArray(sourceFileMappings.map(x => JsArray(JsString(toFile(x._1).getCanonicalPath), JsString(x._2))).toVector).compactPrint, 227 | target.getAbsolutePath, 228 | options 229 | ) 230 | 231 | val results = executeJsOnEngine(engine, shellSource, args, stderrSink, stdoutSink) 232 | import JsTaskProtocol.* 233 | val prp = results.foldLeft(ProblemResultsPair(Nil, Nil)) { 234 | (cumulative, result) => 235 | val prp = result.convertTo[ProblemResultsPair] 236 | ProblemResultsPair( 237 | cumulative.results ++ prp.results, 238 | cumulative.problems ++ prp.problems 239 | ) 240 | } 241 | (prp.results.map(sr => sr.source -> sr.result).toMap, prp.problems) 242 | } 243 | 244 | /* 245 | * For reading/writing binary representations of files. 246 | */ 247 | private implicit object FileFormat extends Format[File] { 248 | 249 | import sbinary.DefaultProtocol.* 250 | 251 | def reads(in: Input): File = file(StringFormat.reads(in)) 252 | 253 | def writes(out: Output, fh: File): Unit = StringFormat.writes(out, fh.getAbsolutePath) 254 | } 255 | 256 | /** 257 | * Primary means of executing a JavaScript shell script for processing source files. unmanagedResources is assumed 258 | * to contain the source files to filter on. 259 | * 260 | * @param task The task to resolve js task settings from - relates to the concrete plugin sub class 261 | * @param config The sbt configuration to use e.g. Assets or TestAssets 262 | * @return A task object 263 | */ 264 | def jsSourceFileTask( 265 | task: TaskKey[Seq[File]], 266 | config: Configuration 267 | ): Def.Initialize[Task[Seq[File]]] = Def.task { 268 | 269 | val nodeModulePaths = (Plugin / nodeModuleDirectories).value.map(_.getCanonicalPath) 270 | val engine = SbtJsEngine.engineTypeToEngine( 271 | (task / engineType).value, 272 | (task / command).value, 273 | LocalEngine.nodePathEnv(nodeModulePaths.to(immutable.Seq)) 274 | ) 275 | 276 | val sources = ((config / task / Keys.sources).value ** ((config / task / includeFilter).value -- (config / task / excludeFilter).value)).get().map(f => new File(f.getCanonicalPath)) 277 | 278 | val logger: Logger = streams.value.log 279 | val taskMsg = (config / task / taskMessage).value 280 | val taskShellSource = (config / task / shellSource).value 281 | val taskSourceDirectories = (config / task / sourceDirectories).value 282 | val taskResources = (config / task / resourceManaged).value 283 | val options = (config / task / jsOptions).value 284 | implicit val fileConverter: FileConverter = (config / Keys.fileConverter ).value 285 | 286 | implicit val opInputHasher: OpInputHasher[File] = (config / task / fileInputHasher).value 287 | val results: (Set[File], Seq[Problem]) = incremental.syncIncremental((config / streams).value.cacheDirectory / "run", sources) { 288 | (modifiedSources: Seq[File]) => 289 | 290 | if (modifiedSources.nonEmpty) { 291 | 292 | logger.info(s"$taskMsg on ${modifiedSources.size} source(s)") 293 | val results: Seq[(FileOpResultMappings, Seq[Problem])] = { 294 | Seq( 295 | executeSourceFilesJs( 296 | engine, 297 | taskShellSource, 298 | modifiedSources.pair(Path.relativeTo(taskSourceDirectories)).map( x => (toFileRef(x._1), x._2)), 299 | taskResources, 300 | options, 301 | m => logger.error(m), 302 | m => logger.info(m), 303 | fileConverter 304 | ) 305 | ) 306 | } 307 | 308 | results.foldLeft((FileOpResultMappings(), Seq[Problem]())) { (allCompletedResults, completedResult) => 309 | 310 | val (prevOpResults, prevProblems) = allCompletedResults 311 | 312 | val (nextOpResults, nextProblems) = completedResult 313 | 314 | (prevOpResults ++ nextOpResults, prevProblems ++ nextProblems) 315 | } 316 | 317 | } else { 318 | (FileOpResultMappings(), Nil) 319 | } 320 | } 321 | 322 | val (filesWritten, problems) = results 323 | 324 | CompileProblems.report((task / reporter).value, problems) 325 | 326 | filesWritten.toSeq 327 | } 328 | 329 | private def addUnscopedJsSourceFileTasks(sourceFileTask: TaskKey[Seq[File]]): Seq[Setting[?]] = { 330 | Seq( 331 | resourceGenerators += sourceFileTask.taskValue, 332 | managedResourceDirectories += ((sourceFileTask / resourceManaged)).value 333 | ) ++ sbt.Project.inTask(sourceFileTask)(Seq( 334 | managedSourceDirectories ++= Def.settingDyn { 335 | sourceDependencies.value.map(_ / resourceManaged).join 336 | }.value, 337 | managedSources ++= Def.taskDyn { 338 | sourceDependencies.value.join.map(_.flatten) 339 | }.value, 340 | sourceDirectories := unmanagedSourceDirectories.value ++ managedSourceDirectories.value, 341 | sources := unmanagedSources.value ++ managedSources.value 342 | )) 343 | } 344 | 345 | /** 346 | * Convenience method to add a source file task into the Asset and TestAsset configurations, along with adding the 347 | * source file tasks in to their respective collection. 348 | * 349 | * @param sourceFileTask The task key to declare. 350 | * @return The settings produced. 351 | */ 352 | def addJsSourceFileTasks(sourceFileTask: TaskKey[Seq[File]]): Seq[Setting[?]] = { 353 | Seq( 354 | (sourceFileTask / sourceDependencies) := Nil, 355 | (Assets / sourceFileTask) := jsSourceFileTask(sourceFileTask, Assets).dependsOn((Plugin / nodeModules)).value, 356 | (TestAssets / sourceFileTask) := jsSourceFileTask(sourceFileTask, TestAssets).dependsOn((Plugin / nodeModules)).value, 357 | Assets / sourceFileTask / resourceManaged := webTarget.value / sourceFileTask.key.label / "main", 358 | TestAssets / sourceFileTask / resourceManaged := webTarget.value / sourceFileTask.key.label / "test", 359 | sourceFileTask := ((Assets / sourceFileTask)).value 360 | ) ++ 361 | inConfig(Assets)(addUnscopedJsSourceFileTasks(sourceFileTask)) ++ 362 | inConfig(TestAssets)(addUnscopedJsSourceFileTasks(sourceFileTask)) 363 | } 364 | 365 | /** 366 | * Execute some arbitrary JavaScript. 367 | * 368 | * This method is intended to assist in building SBT tasks that execute generic JavaScript. For example: 369 | * 370 | * {{{ 371 | * myTask := { 372 | * executeJs(state.value, engineType.value, Seq((nodeModules in Plugin).value.getCanonicalPath, 373 | * baseDirectory.value / "path" / "to" / "myscript.js", Seq("arg1", "arg2")) 374 | * } 375 | * }}} 376 | * 377 | * @param state The SBT state. 378 | * @param engineType The type of engine to use. 379 | * @param command An optional path to the engine. 380 | * @param nodeModules The node modules to provide (if the JavaScript engine in use supports this). 381 | * @param shellSource The script to execute. 382 | * @param args The arguments to pass to the script. 383 | * @param stderrSink A callback to handle the sctipr's error output. 384 | * @param stdoutSink A callback to handle the sctipr's normal output. 385 | * @return A JSON status object if one was sent by the script. A script can send a JSON status object by, as the 386 | * last thing it does, sending a DLE character (0x10) followed by some JSON to std out. 387 | */ 388 | def executeJs( 389 | state: State, 390 | engineType: EngineType.Value, 391 | command: Option[File], 392 | nodeModules: Seq[String], 393 | shellSource: File, 394 | args: Seq[String], 395 | stderrSink: Option[String => Unit] = None, 396 | stdoutSink: Option[String => Unit] = None, 397 | ): Seq[JsValue] = { 398 | val engine = SbtJsEngine.engineTypeToEngine( 399 | engineType, 400 | command, 401 | LocalEngine.nodePathEnv(nodeModules.to(immutable.Seq)) 402 | ) 403 | 404 | executeJsOnEngine(engine, shellSource, args, stderrSink.getOrElse(m => state.log.error(m)), stdoutSink.getOrElse(m => state.log.info(m))) 405 | } 406 | 407 | @deprecated("Use the other executeJs instead", "1.3.0") 408 | def executeJs( 409 | state: State, 410 | engineType: EngineType.Value, 411 | command: Option[File], 412 | nodeModules: Seq[String], 413 | shellSource: File, 414 | args: Seq[String], 415 | timeout: FiniteDuration 416 | ): Seq[JsValue] = executeJs(state, engineType, command, nodeModules, shellSource, args) 417 | } 418 | --------------------------------------------------------------------------------