Skip to content
This repository was archived by the owner on Aug 18, 2020. It is now read-only.

Split GUI into its own jar #109

Merged
merged 4 commits into from
Aug 7, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,3 @@ project/plugins/project/

# Log Output
/log/

# Built gui
/src/main/resources/chatoverflow-gui
8 changes: 7 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ bs := BootstrapUtility.bootstrapGenTask(streams.value.log, s"$scalaMajorVersion$
deploy := BootstrapUtility.prepareDeploymentTask(streams.value.log, scalaMajorVersion)
gui := BuildUtility(streams.value.log).guiTask(guiProjectPath.value, streams.value.cacheDirectory / "gui")

Compile / packageBin := {
BuildUtility(streams.value.log).packageGUITask(guiProjectPath.value, scalaMajorVersion, crossTarget.value)
(Compile / packageBin).value
}

Compile / unmanagedJars := (crossTarget.value ** "chatoverflow-gui*.jar").classpath

// ---------------------------------------------------------------------------------------------------------------------
// UTIL
// ---------------------------------------------------------------------------------------------------------------------
Expand All @@ -117,4 +124,3 @@ lazy val getDependencyList = Def.task[List[ModuleID]] {

// Clears the built GUI dirs on clean
cleanFiles += baseDirectory.value / guiProjectPath.value / "dist"
cleanFiles += baseDirectory.value / "src" / "main" / "resources" / "chatoverflow-gui"
16 changes: 6 additions & 10 deletions project/BootstrapUtility.scala
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,15 @@ object BootstrapUtility {
}

/**
* Copies ONE jar file from the source to all target directories. Useful for single packaged jar files.
*/
* Copies all jar files from the source to all target directories.
*/
private def copyJars(sourceDirectory: String, targetDirectories: List[String], logger: ManagedLogger): Unit = {
val candidates = new File(sourceDirectory)
.listFiles().filter(f => f.isFile && f.getName.toLowerCase.endsWith(".jar"))
if (candidates.length != 1) {
logger warn s"Unable to identify jar file in $sourceDirectory"
} else {
for (targetDirectory <- targetDirectories) {
Files.copy(Paths.get(candidates.head.getAbsolutePath),
Paths.get(s"$targetDirectory/${candidates.head.getName}"))
logger info s"Finished copying file '${candidates.head.getAbsolutePath}' to '$targetDirectory'."
}
for (targetDirectory <- targetDirectories; file <- candidates) {
Files.copy(Paths.get(file.getAbsolutePath),
Paths.get(s"$targetDirectory/${file.getName}"))
logger info s"Finished copying file '${file.getAbsolutePath}' to '$targetDirectory'."
}
}
}
147 changes: 81 additions & 66 deletions project/BuildUtility.scala
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import java.io.{File, IOException}
import java.nio.file.{Files, StandardCopyOption}
import java.util.jar.Manifest

import com.fasterxml.jackson.databind.ObjectMapper
import sbt.internal.util.ManagedLogger
import sbt.util.{FileFunction, FilesInfo}

import scala.io.Source

/**
* A build utility instance handles build tasks and prints debug information using the managed logger.
*
Expand Down Expand Up @@ -139,90 +143,64 @@ class BuildUtility(logger: ManagedLogger) {
return
}

if (installGuiDeps(guiDir, cacheDir).isEmpty)
return // Early return on failure, error has already been displayed

val outDir = buildGui(guiDir, cacheDir)
if (outDir.isEmpty)
return // Again early return on failure

// Copy built gui into resources, will be included in the classpath on execution of the framework
sbt.IO.copyDirectory(outDir.get, new File("src/main/resources/chatoverflow-gui"))
}
}

/**
* Download the dependencies of the gui using npm.
*
* @param guiDir the directory of the gui.
* @param cacheDir a dir, where sbt can store files for caching in the "install" sub-dir.
* @return None, if a error occurs which will be displayed, otherwise the output directory with the built gui.
*/
private def installGuiDeps(guiDir: File, cacheDir: File): Option[File] = {
// Check buildGui for a explanation, it's almost the same.
val packageJson = new File(guiDir, "package.json")

val install = FileFunction.cached(new File(cacheDir, "install"), FilesInfo.hash)(_ => {

logger info "Installing GUI dependencies."

val exitCode = new ProcessBuilder(getNpmCommand :+ "install": _*)
.inheritIO()
.directory(guiDir)
.start()
.waitFor()

if (exitCode != 0) {
logger error "GUI dependencies couldn't be installed, please check above log for further details."
return None
} else {
logger info "GUI dependencies successfully installed."
Set(new File(guiDir, "node_modules"))
if (!executeNpmCommand(guiDir, cacheDir, Set(packageJson), "install",
() => logger error "GUI dependencies couldn't be installed, please check above log for further details.",
() => new File(guiDir, "node_modules")
)) {
return // early return on failure, error has already been displayed
}
})

val input = new File(guiDir, "package.json")
install(Set(input)).headOption
val srcFiles = recursiveFileListing(new File(guiDir, "src"))
val outDir = new File(guiDir, "dist")

executeNpmCommand(guiDir, cacheDir, srcFiles + packageJson, "run build",
() => logger error "GUI couldn't be built, please check above log for further details.",
() => outDir
)
}
}

/**
* Builds the gui using npm.
*
* @param guiDir the directory of the gui.
* @param cacheDir a dir, where sbt can store files for caching in the "build" sub-dir.
* @return None, if a error occurs which will be displayed, otherwise the output directory with the built gui.
*/
private def buildGui(guiDir: File, cacheDir: File): Option[File] = {
* Executes a npm command in the given directory and skips executing the given command
* if no input files have changed and the output file still exists.
*
* @param workDir the directory in which npm should be executed
* @param cacheDir a directory required for caching using sbt
* @param inputs the input files, which will be used for caching.
* If any one of these files change the cache is invalidated.
* @param command the npm command to execute
* @param failed called if npm returned an non-zero exit code
* @param success called if npm returned successfully. Needs to return a file for caching.
* If the returned file doesn't exist the npm command will ignore the cache.
* @return true if npm returned zero as a exit code and false otherwise
*/
private def executeNpmCommand(workDir: File, cacheDir: File, inputs: Set[File], command: String,
failed: () => Unit, success: () => File): Boolean = {
// sbt allows easily to cache our external build using FileFunction.cached
// sbt will only invoke the passed function when at least one of the input files (passed in the last line of this method)
// has been modified. For the gui these input files are all files in the src directory of the gui and the package.json.
// sbt passes these input files to the passed function, but they aren't used, we just instruct npm to build the gui.
// sbt invalidates the cache as well if any of the output files (returned by the passed function) doesn't exist anymore.

val build = FileFunction.cached(new File(cacheDir, "build"), FilesInfo.hash)(_ => {

logger info "Building GUI."

val buildExitCode = new ProcessBuilder(getNpmCommand :+ "run" :+ "build": _*)
val cachedFn = FileFunction.cached(new File(cacheDir, command), FilesInfo.hash) { _ => {
val exitCode = new ProcessBuilder(getNpmCommand ++ command.split("\\s+"): _*)
.inheritIO()
.directory(guiDir)
.directory(workDir)
.start()
.waitFor()

if (buildExitCode != 0) {
logger error "GUI couldn't be built, please check above log for further details."
return None
if (exitCode != 0) {
failed()
return false
} else {
logger info "GUI successfully built."
Set(new File(guiDir, "dist"))
Set(success())
}
})


val srcDir = new File(guiDir, "src")
val packageJson = new File(guiDir, "package.json")
val inputs = recursiveFileListing(srcDir) + packageJson
}
}

build(inputs).headOption
cachedFn(inputs)
true
}

private def getNpmCommand: List[String] = {
Expand All @@ -233,6 +211,43 @@ class BuildUtility(logger: ManagedLogger) {
}
}

def packageGUITask(guiProjectPath: String, scalaMajorVersion: String, crossTargetDir: File): Unit = {
val dir = new File(guiProjectPath, "dist")
if (!dir.exists()) {
logger info "GUI hasn't been compiled. Won't create a jar for it."
return
}

val files = recursiveFileListing(dir)

// contains tuples with the actual file as the first value and the name with directory in the jar as the second value
val jarEntries = files.map(file => file -> s"/chatoverflow-gui/${dir.toURI.relativize(file.toURI).toString}")

val guiVersion = getGUIVersion(guiProjectPath).getOrElse("unknown")

sbt.IO.jar(jarEntries, new File(crossTargetDir, s"chatoverflow-gui_$scalaMajorVersion-$guiVersion.jar"), new Manifest())
}

private def getGUIVersion(guiProjectPath: String): Option[String] = {
val packageJson = new File(s"$guiProjectPath/package.json")
if (!packageJson.exists()) {
logger error "The package.json file of the GUI doesn't exist. Have you cloned the GUI in the correct directory?"
return None
}

val content = Source.fromFile(packageJson)
val version = new ObjectMapper().reader().readTree(content.mkString).get("version").asText()

content.close()

if (version.isEmpty) {
logger warn "The GUI version couldn't be loaded from the package.json."
None
} else {
Option(version)
}
}

/**
* Creates a file listing with all files including files in any sub-dir.
*
Expand Down
5 changes: 4 additions & 1 deletion project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2")
addSbtPlugin("net.virtual-void" % "sbt-dependency-graph" % "0.9.2")

// JSON lib (Jackson) used for parsing the GUI version in the package.json file
libraryDependencies += "org.json4s" %% "json4s-jackson" % "3.5.2"
4 changes: 3 additions & 1 deletion src/main/scala/ScalatraBootstrap.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import org.codeoverflow.chatoverflow.ui.web.rest.connector.ConnectorController
import org.codeoverflow.chatoverflow.ui.web.rest.events.{EventsController, EventsDispatcher}
import org.codeoverflow.chatoverflow.ui.web.rest.plugin.PluginInstanceController
import org.codeoverflow.chatoverflow.ui.web.rest.types.TypeController
import org.codeoverflow.chatoverflow.ui.web.{CodeOverflowSwagger, OpenAPIServlet}
import org.codeoverflow.chatoverflow.ui.web.{CodeOverflowSwagger, GUIServlet, OpenAPIServlet}
import org.scalatra._

/**
Expand All @@ -30,5 +30,7 @@ class ScalatraBootstrap extends LifeCycle {
context.mount(new PluginInstanceController(), "/instances/*", "instances")
context.mount(new ConnectorController(), "/connectors/*", "connectors")
context.mount(new OpenAPIServlet(), "/api-docs")

context.mount(new GUIServlet(), "/*")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package org.codeoverflow.chatoverflow.ui.web

import java.io.File
import java.net.URI
import java.util.jar.JarFile

import org.codeoverflow.chatoverflow.WithLogger
import org.eclipse.jetty.http.MimeTypes
import org.eclipse.jetty.util.Loader
import org.scalatra.{ActionResult, ScalatraServlet}

import scala.io.Source

/**
* A servlet to serve the GUI files of the chatoverflow-gui dir from the classpath.
* This directory is provided if the gui jar is added on the classpath.
* Responds with an error if the gui jar isn't on the classpath.
*/
class GUIServlet extends ScalatraServlet with WithLogger {

private val jarFilePath = {
val res = Loader.getResource(s"/chatoverflow-gui/")

// directory couldn't be found
if (res == null) {
logger error "GUI couldn't be found on the classpath! Has the GUI been built?"
None
} else {
// remove the path inside the jar and only keep the file path to the jar file
val jarPath = res.getFile.split("!").head
logger info s"GUI jar file found at ${new File(".").toURI.relativize(new URI(jarPath))}"

Some(jarPath)
}
}

get("/*") {
if (jarFilePath.isEmpty) {
ActionResult(500, "GUI couldn't be found on the classpath! Has the GUI been built?", Map())
} else {
val jarFile = new JarFile(new File(new URI(jarFilePath.get)))

val path = if (requestPath == "/")
"/index.html"
else
requestPath

val entry = jarFile.getEntry(s"/chatoverflow-gui$path")

val res = if (entry == null) {
ActionResult(404, s"Requested file '$path' couldn't be found in the GUI jar!", Map())
} else {
contentType = MimeTypes.getDefaultMimeByExtension(entry.getName)
Source.fromInputStream(jarFile.getInputStream(entry)).mkString
}

response.setHeader("Cache-Control", "no-cache,no-store")
jarFile.close()
res
}
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package org.codeoverflow.chatoverflow.ui.web

import org.codeoverflow.chatoverflow.{ChatOverflow, WithLogger}
import org.eclipse.jetty.util.resource.Resource
import org.eclipse.jetty.webapp.WebAppContext
import org.scalatra.servlet.ScalatraListener

Expand All @@ -16,9 +15,8 @@ class Server(val chatOverflow: ChatOverflow, val port: Int) extends WithLogger {
private val server = new org.eclipse.jetty.server.Server(port)
private val context = new WebAppContext()
context.setInitParameter("org.eclipse.jetty.servlet.Default.dirAllowed", "false")
context.setInitParameter("org.eclipse.jetty.servlet.Default.cacheControl", "no-cache,no-store")
context setContextPath "/"
context.setBaseResource(Resource.newClassPathResource("/chatoverflow-gui/"))
context setResourceBase "/"
context.addEventListener(new ScalatraListener)

server.setHandler(context)
Expand Down