Skip to content
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
21 changes: 15 additions & 6 deletions plugin/integration/src/MemJs.test.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ object MemJsTests extends TestSuite:

val Right(result) = eval(build.fastLinkJS).runtimeChecked
val report = result.value
val outputDir = report.dest.path
val files = os.list(outputDir).map(_.last).toSet

// Read from the in-memory map rather than disk.
import scala.jdk.CollectionConverters.*
val files = build.hashedOutputFiles.keySet().asScala.toSet
val jsFiles = files.filter(f => f.endsWith(".js") && !f.endsWith(".js.map"))

// No original (unhashed) JS filename should exist.
Expand All @@ -42,10 +44,10 @@ object MemJsTests extends TestSuite:
// No hashed JS filename should contain a hyphen (all "-" must be replaced with "_").
jsFiles.foreach(filename => assert(!filename.contains("-")))

// Every cross-module import must reference a file that actually exists in the output directory.
// Every cross-module import must reference a file that actually exists in the in-memory map.
jsFiles.foreach {
filename =>
val content = os.read(outputDir / filename)
val content = new String(build.hashedOutputFiles.get(filename), "UTF-8")
val imports = ContentHashScalaJSModule.parseJsImports(content)
imports.foreach {
importedName =>
Expand Down Expand Up @@ -90,8 +92,15 @@ object MemJsTests extends TestSuite:
UnitTester(build, resourceFolder / "simple").scoped {
eval =>
val Right(fastResult) = eval(build.fastLinkJS).runtimeChecked
val fastDir = fastResult.value.dest.path
val fastTotalSize = os.list(fastDir).filter(p => os.isFile(p) && p.ext == "js").map(os.size).sum
// fastLinkJS stores output in-memory; compute total JS size from hashedOutputFiles.
import scala.jdk.CollectionConverters.*
val fastTotalSize = build
.hashedOutputFiles
.entrySet()
.asScala
.filter(e => e.getKey.endsWith(".js") && !e.getKey.endsWith(".js.map"))
.map(_.getValue.length.toLong)
.sum

val Right(fullResult) = eval(build.fullLinkJS).runtimeChecked
val fullDir = fullResult.value.dest.path
Expand Down
113 changes: 113 additions & 0 deletions plugin/integration/src/wasmInMem.test.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package io.github.quafadas.sjsls

import mill.api.Discover
import mill.api.Task.Simple
import mill.testkit.TestRootModule
import mill.testkit.UnitTester
import mill.util.TokenReaders.*
import mill.javalib.DepSyntax
import utest.*

object MemWasmTests extends TestSuite:
def tests: Tests = Tests {
test("fastLinkJS WASM output has a hashed wasm file, not hashed JS files") {
object build extends TestRootModule with InMemoryFastLinkHashScalaJSModule:
override def scalaVersion: Simple[String] = "3.8.2"
override def scalaJSExperimentalUseWebAssembly = true

override def mvnDeps = Seq(
mvn"com.raquo::laminar::17.0.0"
)

lazy val millDiscover = Discover[this.type]
end build

val resourceFolder = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))

UnitTester(build, resourceFolder / "simple").scoped {
eval =>

val Right(result) = eval(build.fastLinkJS).runtimeChecked
val report = result.value

// Files live in-memory, not on disk
val memFiles = build.inMemoryOutputDirectory.fileNames().toSet

// The main entry-point JS file must be renamed to a hashed filename.
assert(!memFiles.contains("main.wasm"))
assert(memFiles.contains("__loader.js"))
assert(memFiles.contains("main.js"))

// The WASM binary must be preserved (not renamed).
if !memFiles.exists(_.endsWith(".wasm")) then
throw new java.lang.AssertionError(s"no .wasm file found in: $memFiles")
end if

// The Report's public module must reference the hashed JS file.
assert(report.publicModules.nonEmpty)
val reported = report.publicModules.head.jsFileName
if reported != "main.js" then
throw new java.lang.AssertionError(
s"report module jsFileName should be main.js, was $reported"
)
end if
}
}

test("InMemoryFastLinkHashScalaJSModule fullLinkJS runs wasm-opt and hashes the optimised binary") {
object build extends TestRootModule with InMemoryFastLinkHashScalaJSModule:
override def scalaVersion: Simple[String] = "3.8.2"
override def scalaJSExperimentalUseWebAssembly = true

override def mvnDeps = Seq(
mvn"com.raquo::laminar::17.0.0"
)

lazy val millDiscover = Discover[this.type]
end build

val resourceFolder = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))

UnitTester(build, resourceFolder / "simple").scoped {
eval =>
// Run fastLinkJS first to capture the unoptimised wasm size (lives in-memory).
val Right(_) = eval(build.fastLinkJS).runtimeChecked
val fastWasmNames = build.inMemoryOutputDirectory.fileNames().filter(_.endsWith(".wasm"))
if fastWasmNames.size != 1 then
throw new java.lang.AssertionError(s"Expected exactly 1 wasm file after fastLinkJS, got: $fastWasmNames")
end if
val fastBuf = build.inMemoryOutputDirectory.content(fastWasmNames.head).get
val fastWasmSize = fastBuf.remaining().toLong

// Run fullLinkJS: wasm-opt should produce a smaller binary with a different hash.
// fullLinkJS writes file-based output to Task.dest (not in-memory).
val Right(fullResult) = eval(build.fullLinkJS).runtimeChecked
val fullDir = fullResult.value.dest.path
val fullWasmFiles = os.list(fullDir).filter(p => os.isFile(p) && p.ext == "wasm")
if fullWasmFiles.size != 1 then
throw new java.lang.AssertionError(
s"Expected exactly 1 wasm file after fullLinkJS, got: $fullWasmFiles"
)
end if
val fullWasm = fullWasmFiles.head
val fullWasmSize = os.size(fullWasm)

// The name must be content-hashed (base.<hash>.wasm), not the bare original name.
val nameParts = fullWasm.baseName.split('.')
if nameParts.length < 2 then
throw new java.lang.AssertionError(
s"Expected hashed wasm filename like 'main.<hash>.wasm', got: ${fullWasm.last}"
)
end if

// The optimised binary must be strictly smaller.
if fullWasmSize >= fastWasmSize then
throw new java.lang.AssertionError(
s"wasm-opt did not reduce size: fastLinkJS=$fastWasmSize bytes, fullLinkJS=$fullWasmSize bytes"
)
end if
}
}
}

end MemWasmTests
29 changes: 23 additions & 6 deletions plugin/src/InMemoryFastLinkHashScalaJSModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package io.github.quafadas.sjsls

import java.nio.ByteBuffer
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap

import scala.collection.mutable

Expand Down Expand Up @@ -46,6 +47,7 @@ import mill.api.BuildCtx
*/
trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModule with ScalaJSConfigModule:
val inMemoryOutputDirectory: MemOutputDirectory = MemOutputDirectory()
val hashedOutputFiles: ConcurrentHashMap[String, Array[Byte]] = new ConcurrentHashMap()

override def customLinkerOutputDir: Option[OutputDirectory] =
Some(inMemoryOutputDirectory)
Expand Down Expand Up @@ -147,9 +149,25 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
end try
}

/** Avoids disk IO by keeping everything in memory.
*/
override def fastLinkJS = Task {
val report = super.fastLinkJS()
if scalaJSExperimentalUseWebAssembly() then processWasm(report)
hashedOutputFiles.clear()
if scalaJSExperimentalUseWebAssembly() then
val wasmReport = processWasm(report)
// Populate hashedOutputFiles from inMemoryOutputDirectory after WASM processing
inMemoryOutputDirectory
.fileNames()
.foreach {
name =>
Task.log.debug(s"Adding in-memory file to hashedOutputFiles: $name")
val buf = inMemoryOutputDirectory.content(name).get
val bytes = new Array[Byte](buf.remaining())
buf.get(bytes)
hashedOutputFiles.put(name, bytes)
}
wasmReport
else
// Hash JS files from the in-memory output directory, then write hashed output to Task.dest.
import mill.scalajslib.ContentHashScalaJSModule as C
Expand Down Expand Up @@ -184,8 +202,6 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
val sortedNames = C.topologicalSort(jsFileNames.toList, fileDeps)
val jsHashMapping = mutable.LinkedHashMap.empty[String, String]

os.makeDir.all(Task.dest)

sortedNames.foreach {
name =>
val content = readStr(name)
Expand All @@ -200,7 +216,7 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
"sourceMappingURL=" + name + ".map",
"sourceMappingURL=" + hashedName + ".map"
)
os.write(Task.dest / hashedName, finalContent.getBytes("UTF-8"))
hashedOutputFiles.put(hashedName, finalContent.getBytes("UTF-8"))
}

// Build full mapping including source-map renames.
Expand All @@ -210,13 +226,13 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
}
.toMap

// Write remaining files (source maps, etc.) to Task.dest.
// Store remaining files (source maps, etc.) in hashedOutputFiles.
allFiles
.filterNot(jsFileNames.contains)
.foreach {
name =>
val targetName = fullMapping.getOrElse(name, name)
os.write(Task.dest / targetName, readBytes(name))
hashedOutputFiles.put(targetName, readBytes(name))
}

// Build updated Report.
Expand All @@ -231,6 +247,7 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
moduleKind = m.moduleKind
)
}

api.Report(updatedModules, PathRef(Task.dest))
end if
}
Expand Down
21 changes: 21 additions & 0 deletions plugin/src/ScalaJsWebAppModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,27 @@ trait ScalaJsWebAppModule extends FileBasedContentHashScalaJSModule with ScalaJs
end ScalaJsWebAppModule
trait ScalaJsInMemWebAppModule extends InMemoryFastLinkHashScalaJSModule with ScalaJsRefreshModule:

override def lcs = Task.Worker {
val (site, js) = siteGen()
Task.log.info("Gen lsc (in-memory)")
LiveServerConfig(
baseDir = None,
outDir = Some(js),
port =
com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")),
indexHtmlTemplate = Some(site),
buildTool = io.github.quafadas.sjsls.NoBuildTool(),
openBrowserAt = "/index.html",
preventBrowserOpen = !openBrowser(),
dezombify = dezombify(),
logLevel = logLevel(),
logFile = logFile().fold[Option[String]](None)(p => Some(p.path.toString)),
customRefresh = Some(updateServer),
devToolsWorkspace = Some((moduleDir.toString(), devToolsUuid())),
inMemoryFiles = Some(hashedOutputFiles)
)
}

def publish = Task {
val report = fullLinkJS()
val minifiedDir = report.dest.path
Expand Down
4 changes: 2 additions & 2 deletions plugin/src/refresh_plugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ trait ScalaJsRefreshModule extends ScalaJSConfigModule:

/** Path to write server logs to. When set, logs go to this file instead of the console — useful because Mill watch
* mode captures stdout/stderr per-task, making background server output invisible between evaluations. Example
* override: `def logFile = Task { Some("/tmp/sjsls.log") }`
* ```override def logFile = Task { Some(PathRef(Task.dest / "sjsls.log")) }```
*/
def logFile: Task[Option[PathRef]] = Task {
def logFile: Task.Simple[Option[PathRef]] = Task {
None
}

Expand Down
45 changes: 45 additions & 0 deletions routes/src/appRoute.scala
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
package io.github.quafadas.sjsls

import org.http4s.Header
import org.http4s.HttpRoutes
import org.http4s.MediaType
import org.http4s.Response
import org.http4s.StaticFile
import org.http4s.Status
import org.http4s.dsl.io.*
import org.http4s.headers.`Content-Type`
import org.http4s.server.staticcontent.FileService
import org.http4s.server.staticcontent.fileService

import org.typelevel.ci.CIStringSyntax

import fs2.io.file.Files

import cats.effect.kernel.Async
import cats.syntax.all.*

import scribe.Scribe

private val hashedPattern = ".*\\.[a-f0-9]{8,}\\..*".r

def appRoute[F[_]: Files](stringPath: String)(using f: Async[F]): HttpRoutes[F] = HttpRoutes.of[F] {

Expand All @@ -31,6 +41,41 @@ def appRoute[F[_]: Files](stringPath: String)(using f: Async[F]): HttpRoutes[F]

}

def appRouteInMemory[F[_]](lookup: String => Option[Array[Byte]])(using
f: Async[F],
logger: Scribe[F]
): HttpRoutes[F] =
def contentTypeFor(ext: String): `Content-Type` =
MediaType.forExtension(ext).fold(`Content-Type`(MediaType.application.`octet-stream`))(m => `Content-Type`(m))

def serve(req: org.http4s.Request[F], ext: String): F[Response[F]] =
val key = req.uri.path.renderString.stripPrefix("/")
lookup(key) match
case Some(bytes) =>
val base = Response[F](Status.Ok).withEntity(bytes).withContentType(contentTypeFor(ext))
val resp =
if hashedPattern.matches(key) then
base.putHeaders(
Header.Raw(ci"Cache-Control", "public, max-age=31536000, immutable")
)
else base
logger.debug(
s"[appRouteInMemory] HIT ext=$ext key='$key' size=${bytes.length} bytes hashed=${hashedPattern.matches(key)}"
) >> f.pure(resp)
case None =>
logger.debug(
s"[appRouteInMemory] MISS ext=$ext key='$key'"
) >> f.pure(Response[F](Status.NotFound))
end match
end serve

HttpRoutes.of[F] {
case req @ GET -> Root / fName ~ "js" => serve(req, "js")
case req @ GET -> Root / fName ~ "wasm" => serve(req, "wasm")
case req @ GET -> Root / fName ~ "map" => serve(req, "map")
}
end appRouteInMemory

def fileRoute[F[_]: Async](stringPath: String) = fileService[F](FileService.Config(stringPath))

def spaRoute[F[_]: Async](stringPath: String) = HttpRoutes[F] {
Expand Down
Loading
Loading