Skip to content

Instantly share code, notes, and snippets.

@swsnr
Last active August 26, 2019 21:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save swsnr/225a731c73d36afc0d6a3bc885a3c974 to your computer and use it in GitHub Desktop.
Save swsnr/225a731c73d36afc0d6a3bc885a3c974 to your computer and use it in GitHub Desktop.
Build webapp to webjar with SBT and Yarn
lazy val app = project
.in(file("app"))
.settings(
name := "acme-app",
yarnAppType := YarnAppType.CRA
)
.enablePlugins(YarnPlugin)
.disablePlugins(RevolverPlugin)
lazy val server = project
.in(file("server"))
.settings(
name := "server",
libraryDependencies ++= List(
Dependencies.`akka-stream`,
Dependencies.`akka-slf4j`,
Dependencies.`logback-classic`,
Dependencies.`webjars-locator-core`,
Dependencies.`akka-stream-testkit` % Test,
Dependencies.`akka-http-testkit` % Test,
Dependencies.scalatest % Test,
Dependencies.`mockito-scala-scalatest` % Test
)
)
.dependsOn(app)
import akka.http.scaladsl.model.StatusCodes.MovedPermanently
import akka.http.scaladsl.model.Uri
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import org.webjars.WebJarAssetLocator
import scala.concurrent.duration._
import scala.util.{Failure, Success, Try}
object Directives {
/**
* Complete a get request from a webjar asset
*
* @param locator The webjar asset locator
* @param webJar The webjar name
* @param path The path of the asset within the JAR
*/
def getFromWebjar(locator: WebJarAssetLocator, webJar: String, path: String): Route =
Try(locator.getFullPathExact(webJar, path)) match {
case Success(fullPath) => getFromResource(fullPath)
case Failure(_: IllegalArgumentException) => reject
case Failure(other) => failWith(other)
}
/**
* Get a directory from a webjar.
*
* Special-cases "index.html" at the root: If the root directory is requested
* serve index.html, if index.html is requested redirect permanently to the
* root directory. This creates nicer URLs.
*
* @param locator The asset locator to use
* @param webJar The name of the webjar to serve
*/
def getDirectoryFromWebjar(locator: WebJarAssetLocator, webJar: String): Route =
concat(
// If we are at the root directory add a trailing slash so that relative
// paths to resources are correctly resolved and then serve index.html
(pathEndOrSingleSlash & redirectToTrailingSlashIfMissing(MovedPermanently)) {
getFromWebjar(locator, webJar, "index.html")
},
// If the path immediately ends in /index.html redirect to the directory, that is
// the path already matched by the surrounding pathPrefix.
pathPrefixTest("index.html" ~ PathEnd) {
(extractUri & extractMatchedPath) { (uri, path) =>
redirect(uri.withPath(path ++ Uri.Path.SingleSlash), MovedPermanently)
}
},
// Otherwise if the path points to something below this directory serve the
// asset at that path from the webjar. Require that the path starts with a
// slash to make sure that we only serve assets within the directory.
(rawPathPrefix(Slash) & extractUnmatchedPath) { path =>
getFromWebjar(locator, webJar, path.toString)
}
)
}
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import Directives._
import org.webjars.WebJarAssetLocator
object Routes {
private val webJarAssets = new WebJarAssetLocator()
/**
* Routes for the API console with GraphiQL.
*/
def app: Route =
pathPrefix("app") {
getDirectoryFromWebjar(webJarAssets, "acme-app")
}
}
import java.nio.charset.StandardCharsets.UTF_8
import _root_.io.circe._
import _root_.io.circe.generic.semiauto._
import _root_.io.circe.parser._
import sbt.Keys._
import sbt._
import scala.sys.process._
/**
* SBT plugin to build a frontend application and package it as webjar.
*
* Use Yarn to build different types of frontend applications and package the
* build output as webjar for serving from a Java/Scala HTTP server.
*/
object YarnPlugin extends AutoPlugin {
override def trigger = noTrigger
object autoImport {
sealed trait YarnAppType
object YarnAppType {
/**
* An app build with create-react-app.
*/
case object CRA extends YarnAppType
}
val yarnAppType: SettingKey[YarnAppType] = settingKey[YarnAppType]("The type of app to build")
val yarnInstall: TaskKey[Set[sbt.File]] = taskKey[Set[File]]("Update yarn dependencies")
val yarnBuild: TaskKey[Set[sbt.File]] = taskKey[Set[File]]("Build with yarn")
}
import autoImport._
override def projectSettings: Seq[Def.Setting[_]] = Seq(
// We build a webjar, so remove scala version from artifact
crossPaths := false,
// Set source directories
unmanagedSourceDirectories := {
yarnAppType.value match {
case YarnAppType.CRA => Seq(baseDirectory.value / "src", baseDirectory.value / "public")
}
},
// Task to install dependencies
yarnInstall := {
val log = streams.value.log
val cwd = baseDirectory.value
val run = FileFunction.cached(streams.value.cacheDirectory / "yarn", FileInfo.hash) { _ =>
val exitCode = Process(Seq("yarn"), cwd) ! log
if (exitCode != 0) {
sys.error("yarn failed with exit code $exitCode")
}
(cwd / "node_modules").allPaths.filter(_.isFile).get.toSet
}
// If yarn.lock or package.json change we must reinstall
run(Set(baseDirectory.value / "yarn.lock", baseDirectory.value / "package.json"))
},
// Clean the build directory of the app
cleanFiles += buildDirectory(baseDirectory.value, yarnAppType.value),
// Build specific to the given app type
yarnBuild := {
// Install dependencies before build
val _ = yarnInstall.value
yarnAppType.value match {
case YarnAppType.CRA =>
buildCRA(
baseDirectory = baseDirectory.value,
sourceDirs = unmanagedSourceDirectories.value,
streams = streams.value
)
}
},
// Generate resources in webjar layout so that webjar-locator-core can
// consume assets from the webjar.
Compile / resourceGenerators += Def.task {
val files = yarnBuild.value
val outputDirectory = buildDirectory(baseDirectory.value, yarnAppType.value)
IO.copy(
files.toSeq pair Path.rebase(
outputDirectory,
(Compile / resourceManaged).value / "META-INF" / "resources" / "webjars" / name.value / version.value
)
)
.toSeq
}.taskValue
// TODO: package sources as sourcejar?
)
/**
* Get the build directory for the given app type.
*/
private def buildDirectory(baseDirectory: File, appType: YarnAppType): File =
appType match {
case YarnAppType.CRA => baseDirectory / "build"
}
/**
* Get the build output of a create-react-app build after build.
*
* We read the list of generated files from the `assert-manifest.json` file
* that create-react-app writes to the build directory. It contains a
* "files" key which maps names of input files to names of output files; we
* only care for the latter.
*
* @param buildDir The build directory
* @return A set of all generated files
*/
private def buildOutputOfCRA(buildDir: File): Set[File] = {
val manifest = buildDir / "asset-manifest.json"
readAssetManifest(manifest).generatedFiles(buildDir).toSet + manifest
}
/**
* Build a create-react-app
*
* @param baseDirectory The base directory of the app
* @param sourceDirs Source directories for input caching
* @param streams task streams, i.e. streams.value
* @return All files generated by `yarn build`
*/
private def buildCRA(baseDirectory: File, sourceDirs: Seq[File], streams: TaskStreams): Set[File] = {
val run = FileFunction
.cached(streams.cacheDirectory / "yarn-build", inStyle = FileInfo.hash, outStyle = FileInfo.hash) { _ =>
val exitCode = Process(Seq("yarn", "build"), baseDirectory) ! streams.log
if (exitCode != 0) {
sys.error("yarn build failed with exit code $exitCode")
}
buildOutputOfCRA(buildDirectory(baseDirectory, YarnAppType.CRA))
}
// Rebuild if any source file changes…
val sources = sourceDirs.flatMap(_.allPaths.filter(_.isFile).get).toSet
// …and also if any project file changes because some settings from package.json
// immediately affect build output, and by tracking yarn.lock we avoid having to
// track all files of all node_modules (because yarn.lock is an exact
// representation of what's installed).
val projectFiles = Set(baseDirectory / "package.json", baseDirectory / "yarn.lock")
run(sources ++ projectFiles)
}
final private case class AssetManifest(files: Map[String, String]) {
def generatedFiles(baseDir: File) = files.values.map(baseDir / _)
}
implicit private val decodeAssetManifest: Decoder[AssetManifest] = deriveDecoder[AssetManifest]
/**
* Read an `asset-manifest.json` file to get a list of all files generated by a build.
*/
private def readAssetManifest(manifest: File): AssetManifest =
decode[AssetManifest](IO.read(manifest, UTF_8)).toTry.get
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment