Last active
August 26, 2019 21:07
-
-
Save swsnr/225a731c73d36afc0d6a3bc885a3c974 to your computer and use it in GitHub Desktop.
Build webapp to webjar with SBT and Yarn
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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