Skip to content

Commit 7246613

Browse files
committed
.
1 parent ebdab89 commit 7246613

11 files changed

Lines changed: 291 additions & 18 deletions
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package io.github.quafadas.sjsls
2+
3+
import mill.api.Discover
4+
import mill.api.Task.Simple
5+
import mill.testkit.TestRootModule
6+
import mill.testkit.UnitTester
7+
import mill.util.TokenReaders.*
8+
import mill.javalib.DepSyntax
9+
import utest.*
10+
11+
object MemWasmTests extends TestSuite:
12+
def tests: Tests = Tests {
13+
test("fastLinkJS WASM output has a hashed wasm file, not hashed JS files") {
14+
object build extends TestRootModule with InMemoryFastLinkHashScalaJSModule:
15+
override def scalaVersion: Simple[String] = "3.8.2"
16+
override def scalaJSExperimentalUseWebAssembly = true
17+
18+
override def mvnDeps = Seq(
19+
mvn"com.raquo::laminar::17.0.0"
20+
)
21+
22+
lazy val millDiscover = Discover[this.type]
23+
end build
24+
25+
val resourceFolder = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))
26+
27+
UnitTester(build, resourceFolder / "simple").scoped {
28+
eval =>
29+
30+
val Right(result) = eval(build.fastLinkJS).runtimeChecked
31+
val report = result.value
32+
33+
// Files live in-memory, not on disk
34+
val memFiles = build.inMemoryOutputDirectory.fileNames().toSet
35+
36+
// The main entry-point JS file must be renamed to a hashed filename.
37+
assert(!memFiles.contains("main.wasm"))
38+
assert(memFiles.contains("__loader.js"))
39+
assert(memFiles.contains("main.js"))
40+
41+
42+
// The WASM binary must be preserved (not renamed).
43+
if !memFiles.exists(_.endsWith(".wasm")) then
44+
throw new java.lang.AssertionError(s"no .wasm file found in: $memFiles")
45+
end if
46+
47+
// The Report's public module must reference the hashed JS file.
48+
assert(report.publicModules.nonEmpty)
49+
val reported = report.publicModules.head.jsFileName
50+
if reported != "main.js" then
51+
throw new java.lang.AssertionError(
52+
s"report module jsFileName should be main.js, was $reported"
53+
)
54+
end if
55+
}
56+
}
57+
58+
test("InMemoryFastLinkHashScalaJSModule fullLinkJS runs wasm-opt and hashes the optimised binary") {
59+
object build extends TestRootModule with InMemoryFastLinkHashScalaJSModule:
60+
override def scalaVersion: Simple[String] = "3.8.2"
61+
override def scalaJSExperimentalUseWebAssembly = true
62+
63+
override def mvnDeps = Seq(
64+
mvn"com.raquo::laminar::17.0.0"
65+
)
66+
67+
lazy val millDiscover = Discover[this.type]
68+
end build
69+
70+
val resourceFolder = os.Path(sys.env("MILL_TEST_RESOURCE_DIR"))
71+
72+
UnitTester(build, resourceFolder / "simple").scoped {
73+
eval =>
74+
// Run fastLinkJS first to capture the unoptimised wasm size (lives in-memory).
75+
val Right(_) = eval(build.fastLinkJS).runtimeChecked
76+
val fastWasmNames = build.inMemoryOutputDirectory.fileNames().filter(_.endsWith(".wasm"))
77+
if fastWasmNames.size != 1 then
78+
throw new java.lang.AssertionError(s"Expected exactly 1 wasm file after fastLinkJS, got: $fastWasmNames")
79+
end if
80+
val fastBuf = build.inMemoryOutputDirectory.content(fastWasmNames.head).get
81+
val fastWasmSize = fastBuf.remaining().toLong
82+
83+
// Run fullLinkJS: wasm-opt should produce a smaller binary with a different hash.
84+
// fullLinkJS writes file-based output to Task.dest (not in-memory).
85+
val Right(fullResult) = eval(build.fullLinkJS).runtimeChecked
86+
val fullDir = fullResult.value.dest.path
87+
val fullWasmFiles = os.list(fullDir).filter(p => os.isFile(p) && p.ext == "wasm")
88+
if fullWasmFiles.size != 1 then
89+
throw new java.lang.AssertionError(
90+
s"Expected exactly 1 wasm file after fullLinkJS, got: $fullWasmFiles"
91+
)
92+
end if
93+
val fullWasm = fullWasmFiles.head
94+
val fullWasmSize = os.size(fullWasm)
95+
96+
// The name must be content-hashed (base.<hash>.wasm), not the bare original name.
97+
val nameParts = fullWasm.baseName.split('.')
98+
if nameParts.length < 2 then
99+
throw new java.lang.AssertionError(
100+
s"Expected hashed wasm filename like 'main.<hash>.wasm', got: ${fullWasm.last}"
101+
)
102+
end if
103+
104+
// The optimised binary must be strictly smaller.
105+
if fullWasmSize >= fastWasmSize then
106+
throw new java.lang.AssertionError(
107+
s"wasm-opt did not reduce size: fastLinkJS=$fastWasmSize bytes, fullLinkJS=$fullWasmSize bytes"
108+
)
109+
end if
110+
}
111+
}
112+
}
113+
114+
end MemWasmTests

plugin/src/InMemoryFastLinkHashScalaJSModule.scala

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.github.quafadas.sjsls
22

33
import java.nio.ByteBuffer
44
import java.security.MessageDigest
5+
import java.util.concurrent.ConcurrentHashMap
56

67
import scala.collection.mutable
78

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

5052
override def customLinkerOutputDir: Option[OutputDirectory] =
5153
Some(inMemoryOutputDirectory)
@@ -149,7 +151,20 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
149151

150152
override def fastLinkJS = Task {
151153
val report = super.fastLinkJS()
152-
if scalaJSExperimentalUseWebAssembly() then processWasm(report)
154+
hashedOutputFiles.clear()
155+
if scalaJSExperimentalUseWebAssembly() then
156+
val wasmReport = processWasm(report)
157+
// Populate hashedOutputFiles from inMemoryOutputDirectory after WASM processing
158+
inMemoryOutputDirectory
159+
.fileNames()
160+
.foreach {
161+
name =>
162+
val buf = inMemoryOutputDirectory.content(name).get
163+
val bytes = new Array[Byte](buf.remaining())
164+
buf.get(bytes)
165+
hashedOutputFiles.put(name, bytes)
166+
}
167+
wasmReport
153168
else
154169
// Hash JS files from the in-memory output directory, then write hashed output to Task.dest.
155170
import mill.scalajslib.ContentHashScalaJSModule as C
@@ -184,8 +199,6 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
184199
val sortedNames = C.topologicalSort(jsFileNames.toList, fileDeps)
185200
val jsHashMapping = mutable.LinkedHashMap.empty[String, String]
186201

187-
os.makeDir.all(Task.dest)
188-
189202
sortedNames.foreach {
190203
name =>
191204
val content = readStr(name)
@@ -200,7 +213,7 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
200213
"sourceMappingURL=" + name + ".map",
201214
"sourceMappingURL=" + hashedName + ".map"
202215
)
203-
os.write(Task.dest / hashedName, finalContent.getBytes("UTF-8"))
216+
hashedOutputFiles.put(hashedName, finalContent.getBytes("UTF-8"))
204217
}
205218

206219
// Build full mapping including source-map renames.
@@ -210,13 +223,13 @@ trait InMemoryFastLinkHashScalaJSModule extends FileBasedContentHashScalaJSModul
210223
}
211224
.toMap
212225

213-
// Write remaining files (source maps, etc.) to Task.dest.
226+
// Store remaining files (source maps, etc.) in hashedOutputFiles.
214227
allFiles
215228
.filterNot(jsFileNames.contains)
216229
.foreach {
217230
name =>
218231
val targetName = fullMapping.getOrElse(name, name)
219-
os.write(Task.dest / targetName, readBytes(name))
232+
hashedOutputFiles.put(targetName, readBytes(name))
220233
}
221234

222235
// Build updated Report.

plugin/src/ScalaJsWebAppModule.scala

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ trait ScalaJsWebAppModule extends FileBasedContentHashScalaJSModule with ScalaJs
4545
end ScalaJsWebAppModule
4646
trait ScalaJsInMemWebAppModule extends InMemoryFastLinkHashScalaJSModule with ScalaJsRefreshModule:
4747

48+
override def lcs = Task.Worker {
49+
val (site, js) = siteGen()
50+
Task.log.info("Gen lsc (in-memory)")
51+
LiveServerConfig(
52+
baseDir = None,
53+
outDir = Some(js),
54+
port =
55+
com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")),
56+
indexHtmlTemplate = Some(site),
57+
buildTool = io.github.quafadas.sjsls.NoBuildTool(),
58+
openBrowserAt = "/index.html",
59+
preventBrowserOpen = !openBrowser(),
60+
dezombify = dezombify(),
61+
logLevel = logLevel(),
62+
logFile = logFile().fold[Option[String]](None)(p => Some(p.path.toString)),
63+
customRefresh = Some(updateServer),
64+
devToolsWorkspace = Some((moduleDir.toString(), devToolsUuid())),
65+
inMemoryFiles = Some(hashedOutputFiles)
66+
)
67+
}
68+
4869
def publish = Task {
4970
val report = fullLinkJS()
5071
val minifiedDir = report.dest.path

plugin/src/refresh_plugin.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ trait ScalaJsRefreshModule extends ScalaJSConfigModule:
121121

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

routes/src/appRoute.scala

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package io.github.quafadas.sjsls
22

33
import org.http4s.HttpRoutes
4+
import org.http4s.MediaType
45
import org.http4s.Response
56
import org.http4s.StaticFile
67
import org.http4s.Status
78
import org.http4s.dsl.io.*
9+
import org.http4s.headers.`Content-Type`
810
import org.http4s.server.staticcontent.FileService
911
import org.http4s.server.staticcontent.fileService
1012

@@ -31,6 +33,28 @@ def appRoute[F[_]: Files](stringPath: String)(using f: Async[F]): HttpRoutes[F]
3133

3234
}
3335

36+
def appRouteInMemory[F[_]](lookup: String => Option[Array[Byte]])(using f: Async[F]): HttpRoutes[F] =
37+
def contentTypeFor(ext: String): `Content-Type` =
38+
MediaType.forExtension(ext).fold(`Content-Type`(MediaType.application.`octet-stream`))(m => `Content-Type`(m))
39+
40+
HttpRoutes.of[F] {
41+
case req @ GET -> Root / fName ~ "js" =>
42+
lookup(req.uri.path.renderString.stripPrefix("/")) match
43+
case Some(bytes) => f.pure(Response[F](Status.Ok).withEntity(bytes).withContentType(contentTypeFor("js")))
44+
case None => f.pure(Response[F](Status.NotFound))
45+
46+
case req @ GET -> Root / fName ~ "wasm" =>
47+
lookup(req.uri.path.renderString.stripPrefix("/")) match
48+
case Some(bytes) => f.pure(Response[F](Status.Ok).withEntity(bytes).withContentType(contentTypeFor("wasm")))
49+
case None => f.pure(Response[F](Status.NotFound))
50+
51+
case req @ GET -> Root / fName ~ "map" =>
52+
lookup(req.uri.path.renderString.stripPrefix("/")) match
53+
case Some(bytes) => f.pure(Response[F](Status.Ok).withEntity(bytes).withContentType(contentTypeFor("map")))
54+
case None => f.pure(Response[F](Status.NotFound))
55+
}
56+
end appRouteInMemory
57+
3458
def fileRoute[F[_]: Async](stringPath: String) = fileService[F](FileService.Config(stringPath))
3559

3660
def spaRoute[F[_]: Async](stringPath: String) = HttpRoutes[F] {

routes/test/src/build.routes.test.scala

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,64 @@ class BuildRoutesSuite extends CatsEffectSuite:
5757

5858
}
5959

60+
test("appRouteInMemory serves JS from lookup") {
61+
val jsContent = "console.log('hello');".getBytes("UTF-8")
62+
val lookup: String => Option[Array[Byte]] = {
63+
case "main.abc12345.js" => Some(jsContent)
64+
case _ => None
65+
}
66+
val route = appRouteInMemory[IO](lookup)
67+
68+
val found = route(makeReq("/main.abc12345.js")).map(_.status).getOrElse(Status.NotFound)
69+
val notFound = route(makeReq("/missing.js")).map(_.status).getOrElse(Status.NotFound)
70+
val body = route(makeReq("/main.abc12345.js"))
71+
.semiflatMap(_.body.compile.to(Array))
72+
.getOrElse(Array.empty[Byte])
73+
74+
assertIO(found, Status.Ok) >>
75+
assertIO(notFound, Status.NotFound) >>
76+
assertIO(body.map(_.toSeq), jsContent.toSeq)
77+
}
78+
79+
test("appRouteInMemory serves WASM from lookup") {
80+
val wasmContent = Array[Byte](0, 97, 115, 109) // fake wasm magic bytes
81+
val lookup: String => Option[Array[Byte]] = {
82+
case "module.abc12345.wasm" => Some(wasmContent)
83+
case _ => None
84+
}
85+
val route = appRouteInMemory[IO](lookup)
86+
87+
val found = route(makeReq("/module.abc12345.wasm")).map(_.status).getOrElse(Status.NotFound)
88+
val notFound = route(makeReq("/missing.wasm")).map(_.status).getOrElse(Status.NotFound)
89+
90+
assertIO(found, Status.Ok) >>
91+
assertIO(notFound, Status.NotFound)
92+
}
93+
94+
test("appRouteInMemory serves source maps from lookup") {
95+
val mapContent = """{"version":3}""".getBytes("UTF-8")
96+
val lookup: String => Option[Array[Byte]] = {
97+
case "main.abc12345.js.map" => Some(mapContent)
98+
case _ => None
99+
}
100+
val route = appRouteInMemory[IO](lookup)
101+
102+
val found = route(makeReq("/main.abc12345.js.map")).map(_.status).getOrElse(Status.NotFound)
103+
val notFound = route(makeReq("/other.map")).map(_.status).getOrElse(Status.NotFound)
104+
105+
assertIO(found, Status.Ok) >>
106+
assertIO(notFound, Status.NotFound)
107+
}
108+
109+
test("appRouteInMemory does not match non-JS/WASM/map extensions") {
110+
val lookup: String => Option[Array[Byte]] = _ => Some(Array.empty[Byte])
111+
val route = appRouteInMemory[IO](lookup)
112+
113+
val html = route(makeReq("/index.html")).map(_.status).getOrElse(Status.NotFound)
114+
val css = route(makeReq("/styles.css")).map(_.status).getOrElse(Status.NotFound)
115+
116+
assertIO(html, Status.NotFound) >>
117+
assertIO(css, Status.NotFound)
118+
}
119+
60120
end BuildRoutesSuite

sjsls/src/LiveServerConfig.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,6 @@ case class LiveServerConfig(
2626
dezombify: Boolean = true,
2727
logFile: Option[String] = None,
2828
customRefresh: Option[Topic[IO, Unit]] = None,
29-
devToolsWorkspace: Option[(String, String)] = None
29+
devToolsWorkspace: Option[(String, String)] = None,
30+
inMemoryFiles: Option[java.util.concurrent.ConcurrentHashMap[String, Array[Byte]]] = None
3031
)

sjsls/src/liveServer.scala

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ object LiveServer extends IOApp:
6161
val uuid = uuidOpt.getOrElse(java.util.UUID.randomUUID().toString)
6262
Some((root, uuid))
6363
case _ => None
64-
}
64+
},
65+
None.pure[Opts]
6566
).mapN(LiveServerConfig.apply)
6667

6768
def main(lsc: LiveServerConfig): Resource[IO, Server] =
@@ -173,10 +174,13 @@ object LiveServer extends IOApp:
173174
lsc.clientRoutingPrefix,
174175
lsc.injectPreloads,
175176
lsc.buildTool,
176-
lsc.devToolsWorkspace
177+
lsc.devToolsWorkspace,
178+
lsc.inMemoryFiles
177179
)(logger)
178180

179-
_ <- updateMapRef(outDirPath, fileToHashRef)(logger).toResource
181+
_ <- lsc.inMemoryFiles match
182+
case Some(files) => updateMapRefFromMemory(files, fileToHashRef)(logger).toResource
183+
case None => updateMapRef(outDirPath, fileToHashRef)(logger).toResource
180184
// _ <- stylesDir.fold(Resource.unit)(sd => seedMapOnStart(sd, mr))
181185
_ <- fileWatcher(outDirPath, fileToHashRef, linkingTopic, refreshTopic)(logger)
182186
// Only watch the indexHtmlTemplate dir for changes when the caller has NOT supplied a

0 commit comments

Comments
 (0)