Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Failure to parse Webpack stats fails the build #424

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
45 changes: 28 additions & 17 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/BundlerFile.scala
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,12 @@ object BundlerFile {
/**
* Returns the Library identifying the asset produced by scala.js through webpack stats
*/
def asLibrary(stats: Option[WebpackStats]): Library =
Library(project,
stats.flatMap { s =>
s.resolveAsset(targetDir, project)
}.getOrElse(targetDir.resolve(Library.fileName(project)).toFile),
stats.map { s =>
s.resolveAllAssets(targetDir)
}.getOrElse(Nil)
)
def asLibrary(stats: WebpackStats): Library =
Library(
project,
resolveApplicationBundleFile(stats),
resolveApplicationAssets(stats)
)

/**
* Returns library from a set of cached files
Expand All @@ -88,14 +85,28 @@ object BundlerFile {
/**
* Returns the Application for this configuration identifying the asset produced by scala.js through webpack stats
*/
def asApplicationBundle(stats: Option[WebpackStats]): ApplicationBundle =
ApplicationBundle(project,
stats.flatMap { s =>
s.resolveAsset(targetDir, project)
}.getOrElse(targetDir.resolve(ApplicationBundle.fileName(project)).toFile),
stats.map { s =>
s.resolveAllAssets(targetDir)
}.getOrElse(Nil))
def asApplicationBundle(stats: WebpackStats): ApplicationBundle =
ApplicationBundle(
project,
resolveApplicationBundleFile(stats),
resolveApplicationAssets(stats)
)

private def resolveApplicationBundleFile(stats: WebpackStats) = {
stats.resolveAsset(targetDir, project)
.getOrElse(throw new RuntimeException("Webpack failed to create application bundle"))
}

private def resolveApplicationAssets(stats: WebpackStats) = {
val assets = stats.resolveAllAssets(targetDir)
val nonExisting = assets.filter(!_.exists())
if (nonExisting.nonEmpty) {
throw new RuntimeException(
s"Webpack failed to create application assets:\n" +
s"${nonExisting.map(asset => s"${asset.getAbsolutePath}").mkString("\n")}")
}
assets
}

/**
* Returns an application bundle from a set of cached files
Expand Down
82 changes: 47 additions & 35 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/Stats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
package scalajsbundler

import play.api.libs.json._
import play.api.libs.functional.syntax._
import sbt.Logger
import scala.math.max
import java.io.File
Expand Down Expand Up @@ -50,18 +49,48 @@ object Stats {

}

final case class WebpackError(moduleName: String, message: String, loc: String)
sealed trait Issue {
val message: String
val moduleName: Option[String]
val loc: Option[String]
val stack: Option[String]
val details: Option[String]

def format(): String = {
List(
moduleName.map("in "+_),
Some("Message: "+message),
loc.map("Loc: "+_),
stack.map("Stack: "+_),
details.map("Details: "+_)
).flatten.mkString("\n")
}
}

final case class WebpackWarning(moduleName: String, message: String)
final case class WebpackError(
message: String,
moduleName: Option[String],
loc: Option[String],
stack: Option[String],
details: Option[String]
) extends Issue

final case class WebpackWarning(
message: String,
moduleName: Option[String],
loc: Option[String],
stack: Option[String],
details: Option[String]
) extends Issue

final case class WebpackStats(
version: String,
hash: String,
time: Long,
time: Option[Long],
outputPath: Option[Path],
errors: List[WebpackError],
warnings: List[WebpackWarning],
assets: List[Asset]
errors: List[WebpackError] = Nil,
warnings: List[WebpackWarning] = Nil,
assets: List[Asset] = Nil
) {

/**
Expand All @@ -70,7 +99,12 @@ object Stats {
def print(log: Logger): Unit = {
import formatting._
// Print base info
List(s"Version: $version", s"Hash: $hash", s"Time: ${time}ms", s"Path: ${outputPath.getOrElse("<default>")}").foreach(x => log.info(x))
List(
Some(s"Version: $version"),
Some(s"Hash: $hash"),
time.map(time => s"Time: ${time}ms"),
outputPath.map(s"Path: "+_)
).flatten.foreach(x => log.info(x))
log.info("")
// Print the assets
assets.map { a =>
Expand Down Expand Up @@ -107,32 +141,10 @@ object Stats {
assets.map(a => outputPath.getOrElse(altDir).resolve(a.name).toFile)
}

implicit val assetsReads: Reads[Asset] = (
(JsPath \ "name").read[String] and
(JsPath \ "size").read[Long] and
(JsPath \ "emitted").read[Boolean] and
(JsPath \ "chunkNames").read[List[String]]
)(Asset.apply _)

implicit val errorReads: Reads[WebpackError] = (
(JsPath \ "moduleName").read[String] and
(JsPath \ "message").read[String] and
(JsPath \ "loc").read[String]
)(WebpackError.apply _)

implicit val warningReads: Reads[WebpackWarning] = (
(JsPath \ "moduleName").read[String] and
(JsPath \ "message").read[String]
)(WebpackWarning.apply _)

implicit val statsReads: Reads[WebpackStats] = (
(JsPath \ "version").read[String] and
(JsPath \ "hash").read[String] and
(JsPath \ "time").read[Long] and
(JsPath \ "outputPath").readNullable[String].map(x => x.map(new File(_).toPath)) and // It seems webpack 2 doesn't produce outputPath
(JsPath \ "errors").read[List[WebpackError]] and
(JsPath \ "warnings").read[List[WebpackWarning]] and
(JsPath \ "assets").read[List[Asset]]
)(WebpackStats.apply _)
implicit val assetsReads: Reads[Asset] = Json.reads[Asset]
implicit val errorReads: Reads[WebpackError] = Json.reads[WebpackError]
implicit val warningReads: Reads[WebpackWarning] = Json.reads[WebpackWarning]
implicit val pathReads: Reads[Path] = Reads.StringReads.map(new File(_).toPath)
implicit val statsReads: Reads[WebpackStats] = Json.using[Json.WithDefaultValues].reads[WebpackStats]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes don't seem to have anything to do with the purpose of this PR, have they?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sjrd It does because you need to update the JSON decoders to parse the new Option types that have been added. So rather than explicitly modify the individual fields it parses as Options, this solves the problem using derived decoders, which is also fewer lines and I don't think there are any downsides since it's compile-time derivation (maybe slightly longer time taken compiling?)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the model has changed, Reads do have to be modified, and derived decoders are a simplification of those changes.


}
95 changes: 47 additions & 48 deletions sbt-scalajs-bundler/src/main/scala/scalajsbundler/Webpack.scala
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,10 @@ object Webpack {
log.info("Bundling the application with its NPM dependencies")
val args = extraArgs ++: Seq("--config", configFile.absolutePath)
val stats = Webpack.run(nodeArgs: _*)(args: _*)(targetDir, log)
stats.foreach(_.print(log))
stats.print(log)

// Attempt to discover the actual name produced by webpack indexing by chunk name and discarding maps
val bundle = generatedWebpackConfigFile.asApplicationBundle(stats)
assert(bundle.file.exists(), "Webpack failed to create application bundle")
assert(bundle.assets.forall(_.exists()), "Webpack failed to create application assets")
bundle
}

Expand Down Expand Up @@ -220,55 +218,51 @@ object Webpack {

val args = extraArgs ++: Seq("--config", configFile.absolutePath)
val stats = Webpack.run(nodeArgs: _*)(args: _*)(generatedWebpackConfigFile.targetDir.toFile, log)
stats.foreach(_.print(log))
stats.print(log)

val library = generatedWebpackConfigFile.asLibrary(stats)
assert(library.file.exists, "Webpack failed to create library file")
assert(library.assets.forall(_.exists), "Webpack failed to create library assets")
library
}

private def jsonOutput(cmd: Seq[String], logger: Logger)(in: InputStream): Option[WebpackStats] = {
Try {
val parsed = Json.parse(in)
parsed.validate[WebpackStats] match {
case JsError(e) =>
logger.error("Error parsing webpack stats output")
// In case of error print the result and return None. it will be ignored upstream
e.foreach {
case (p, v) => logger.error(s"$p: ${v.mkString(",")}")
}
None
case JsSuccess(p, _) =>
if (p.warnings.nonEmpty || p.errors.nonEmpty) {
logger.info("")
// Filtering is a workaround for #111
p.warnings.filterNot(_.message.contains("https://raw.githubusercontent.com")).foreach { warning =>
logger.warn(s"WARNING in ${warning.moduleName}")
logger.warn(warning.message)
logger.warn("\n")
}
p.errors.foreach { error =>
logger.error(s"ERROR in ${error.moduleName} ${error.loc}")
logger.error(error.message)
logger.error("\n")
}
private def jsonOutput(cmd: Seq[String], logger: Logger)(in: InputStream): Try[WebpackStats] = {
Try(Json.parse(in))
.fold(
e => Failure(
new RuntimeException(
"Failure on parsing the output of webpack\n" +
"You can try to manually execute the command\n" +
s"${cmd.mkString(" ")}",
e
)
),
parsed => {
parsed.validate[WebpackStats] match {
case JsError(e) =>
Failure(
new RuntimeException(
"Error parsing webpack stats output\n" +
s"${e.map { case (p, v) => s"$p: ${v.mkString(",")}"}.mkString("\n")}"
)
)
case JsSuccess(p, _) =>
if (p.warnings.nonEmpty || p.errors.nonEmpty) {
logger.info("")
// Filtering is a workaround for #111
p.warnings.filterNot(_.message.contains("https://raw.githubusercontent.com")).foreach { warning =>
logger.warn(s"WARNING")
logger.warn(warning.format())
logger.warn("\n")
}
p.errors.foreach { error =>
logger.error(s"ERROR")
logger.error(error.format())
logger.error("\n")
}
}
Success(p)
}
Some(p)
}
} match {
case Success(x) =>
x
case Failure(e) =>
// In same cases errors are not reported on the json output but comes on stdout
// where they cannot be parsed as json. The best we can do here is to suggest
// running the command manually
logger.error(s"Failure on parsing the output of webpack: ${e.getMessage}")
logger.error(s"You can try to manually execute the command")
logger.error(cmd.mkString(" "))
logger.error("\n")
None
}
}
)
}

/**
Expand All @@ -279,11 +273,16 @@ object Webpack {
* @param workingDir Working directory in which the Nodejs will be run (where there is the `node_modules` subdirectory)
* @param log Logger
*/
def run(nodeArgs: String*)(args: String*)(workingDir: File, log: Logger): Option[WebpackStats] = {
def run(nodeArgs: String*)(args: String*)(workingDir: File, log: Logger): WebpackStats = {
val webpackBin = workingDir / "node_modules" / "webpack" / "bin" / "webpack"
val params = nodeArgs ++ Seq(webpackBin.absolutePath, "--profile", "--json") ++ args
val cmd = "node" +: params
Commands.run(cmd, workingDir, log, jsonOutput(cmd, log)).fold(sys.error, _.flatten)
log.debug(s"Running command [${cmd.mkString(" ")}]")
Commands.run(cmd, workingDir, log, jsonOutput(cmd, log))
.fold(
sys.error,
Comment on lines +282 to +283
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're going to throw when the Try is a Failure, you as well do

val runResult = Commands.run(...).get

But then again, why change Commands.run to return a Try in that case, instead of directly throwing the exception?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to modify the existing code as little as possible, I see that expression was lost in a way with nesting of all these monads.

Command.run returns an Either[String, Try[T]]] where:

  1. Left means that command returned with a non-zero exit code, so we sys.error it.
  2. Right(None) means that command returned with a zero exit code, but had no output, so we throw a throw new RuntimeException("Failure on returning webpack stats from command output")
  3. Right(Some(Failure)) means that command returned with zero exit code and had output, but output parsing to WebpackStats with jsonOutput has failed.
  4. Right(Some(Success) means that command returned with zero exit code, had output and output parsing to WebpackStats with jsonOutput has succeeded.

So current implementation looks more funky because I wanted to separate the cases 2) and 3) - failures are related to command output, but mean different things.

Maybe this would have looked clearer?

Commands.run(cmd, workingDir, log, jsonOutput(cmd, log))
  .fold(
    sys.error,
    _.fold(throw new RuntimeException("Command succeeded, but returned no webpack stats output"))(_.get)
  )

Otherwise Command.run would benefit from an improvement to return type specificity.

Knowing this, what further modifications would you suggest?

_.map(_.get).getOrElse(throw new RuntimeException("Failure on returning webpack stats from command output"))
)
}

}
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
package scalajsbundler.util

import sbt.Logger
import java.io.{InputStream, File}
import java.io.{File, InputStream}

import scala.sys.process.Process
Comment on lines 3 to 6
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spurious changes.

import scala.sys.process.BasicIO
import scala.sys.process.ProcessLogger
import scala.util.Try

object Commands {

def run[A](cmd: Seq[String], cwd: File, logger: Logger, outputProcess: InputStream => A): Either[String, Option[A]] = {
def run[A](cmd: Seq[String], cwd: File, logger: Logger, outputProcess: InputStream => Try[A]): Either[String, Option[Try[A]]] = {
val toErrorLog = (is: InputStream) => {
scala.io.Source.fromInputStream(is).getLines.foreach(msg => logger.error(msg))
is.close()
}

// Unfortunately a var is the only way to capture the result
var result: Option[A] = None
var result: Option[Try[A]] = None
def outputCapture(o: InputStream): Unit = {
result = Some(outputProcess(o))
o.close()
Expand All @@ -34,7 +36,7 @@ object Commands {
}

def run(cmd: Seq[String], cwd: File, logger: Logger): Unit = {
val toInfoLog = (is: InputStream) => scala.io.Source.fromInputStream(is).getLines.foreach(msg => logger.info(msg))
val toInfoLog = (is: InputStream) => Try(scala.io.Source.fromInputStream(is).getLines.foreach(msg => logger.info(msg)))
run(cmd, cwd, logger, toInfoLog).fold(sys.error, _ => ())
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
scalaVersion := "2.12.8"

useYarn := true

yarnExtraArgs in Compile := Seq("--silent")

scalaJSUseMainModuleInitializer := true

webpackConfigFile := Some(baseDirectory.value / "webpack.config.js")

enablePlugins(ScalaJSBundlerPlugin)

ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
val scalaJSVersion = sys.props.getOrElse("scalajs.version", sys.error("'scalajs.version' environment variable is not defined"))
val scalaJSBundlerVersion = sys.props.getOrElse("plugin.version", sys.error("'plugin.version' environment variable is not set"))

addSbtPlugin("org.scala-js" % "sbt-scalajs" % scalaJSVersion)

addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % scalaJSBundlerVersion)

ivyLoggingLevel in ThisBuild := UpdateLogging.Quiet
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package example

object Main {
def main(args: Array[String]): Unit = {
println("webpack-stats main")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
> fullOptJS::webpack
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand how this test relates to the fix. If this PR is about failing a build, there should a be at least one negative test line (starting with -).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test would fail in current main branch, the changes added introduce fixes that actually make it pass. Is that fine?

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Load the config generated by scalajs-bundler
var config = require('./scalajs.webpack.config');

module.exports = config;

module.exports.performance = {
maxEntrypointSize: 1 // force warning
};

module.exports.output.filename = '[name].[contenthash].bundle.js'