From multiarch-scala
Scala.js browser packaging — compilation pipeline, module kinds, HTML wrappers, asset bundling, and dev/production workflows
How this skill is triggered — by the user, by Claude, or both
Slash command
/multiarch-scala:browser-packagingThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This is **not** a multiarch-scala feature. Everything described here is pure
This is not a multiarch-scala feature. Everything described here is pure
sbt-scalajs. The skill exists because projects using sbt-multiarch-scala
often also target the browser via Scala.js, and browser packaging has enough
moving parts to warrant a dedicated reference.
Scala source (.scala)
│
▼ scalac with Scala.js compiler plugin
Scala.js IR (.sjsir files in target/)
│
▼ Scala.js linker (fastLinkJS or fullLinkJS)
JavaScript output (.js files)
│
▼ wrapped in index.html + assets
Browser-ready application
The linker is the Scala.js-specific step. It reads .sjsir intermediate
representation, resolves dependencies, eliminates dead code, and emits
JavaScript (or optionally WASM). Two linker modes exist:
| Task | Speed | Output size | Use case |
|---|---|---|---|
fastLinkJS | Fast, incremental | Large, unoptimized | Development |
fullLinkJS | Slow (Closure Compiler) | Small, optimized | Production |
// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0")
// build.sbt
lazy val browser = project.in(file("browser"))
.enablePlugins(ScalaJSPlugin)
.settings(
scalaVersion := "3.8.3",
scalaJSUseMainModuleInitializer := true,
Compile / mainClass := Some("myapp.Main")
)
// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.10")
// build.sbt
val scala3 = "3.8.3"
lazy val core = (projectMatrix in file("core"))
.settings(name := "my-app-core", scalaVersion := scala3)
.jvmPlatform(scalaVersions = Seq(scala3))
.jsPlatform(scalaVersions = Seq(scala3), settings = Seq(
scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }
))
.nativePlatform(scalaVersions = Seq(scala3))
When using projectMatrix, the JS subproject ID is coreJS3 (the JS suffix
plus Scala binary version). JVM is core3 (no suffix), Native is coreNative3.
Set to true for applications that need a main method entry point.
Without this, the linker produces a library — it exports symbols but nothing
executes on load.
scalaJSUseMainModuleInitializer := true
Your Main object must extend scala.scalajs.js.annotation.JSExportTopLevel
or simply have a standard def main(args: Array[String]): Unit.
Controls how the JavaScript output is structured.
import org.scalajs.linker.interface.ModuleKind
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }
| ModuleKind | Output | Loading | Notes |
|---|---|---|---|
NoModule (default) | Single .js file, no import/export | <script src="main.js"> | Simplest; no module system |
CommonJSModule | require()-style modules | Node.js / bundler | Not directly loadable in browsers |
ESModule | ES module with import/export | <script type="module"> | Browser-native; required for WASM backend |
Recommendation: Use ESModule for browser targets. It enables native
browser module loading, better tree-shaking by bundlers, and is the only
option compatible with the experimental WASM backend.
sbt --client 'browser/fastLinkJS'
# or for projectMatrix:
sbt --client 'coreJS3/fastLinkJS'
target/scala-3.x.y/<project>-fastopt/sbt --client 'browser/fullLinkJS'
# or for projectMatrix:
sbt --client 'coreJS3/fullLinkJS'
target/scala-3.x.y/<project>-opt/The linker produces its output in a directory, not a single file. For
ESModule, the main entry point is typically main.js. For NoModule,
it may be named after the project.
target/scala-3.8.3/my-app-fastopt/
├── main.js # Entry point
├── main.js.map # Source map (development only)
└── internal-*.js # Additional modules (ESModule split)
The browser needs an index.html to load the JavaScript output. This is
not generated by sbt-scalajs — you create it yourself.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My App</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module" src="main.js"></script>
</body>
</html>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>My App</title>
</head>
<body>
<canvas id="canvas"></canvas>
<script src="main.js"></script>
</body>
</html>
dist/
├── index.html
├── main.js # Copied from fullLinkJS output
├── main.js.map # Optional, for debugging
└── assets/
├── textures/
│ └── player.png
├── audio/
│ └── bgm.ogg
└── data/
└── levels.json
Scala.js does not have access to java.io.File. Resources from
src/main/resources/ are not automatically available in the browser.
You must explicitly copy resource files alongside the JavaScript output
and load them via HTTP (e.g., fetch(), XMLHttpRequest, or <img> tags).
src/main/resources/ (or a dedicated assets/ directory)lazy val packageBrowser = taskKey[File]("Package browser build")
packageBrowser := {
val linkResult = (Compile / fullLinkJS).value
val outputDir = target.value / "browser-dist"
// Copy JS output
val jsDir = linkResult.data.publicModules.head.jsFileName
val jsSource = (Compile / fullLinkJS / scalaJSLinkerOutputDirectory).value
IO.copyDirectory(jsSource, outputDir)
// Copy resources
val resourceDir = (Compile / resourceDirectory).value
if (resourceDir.exists()) {
IO.copyDirectory(resourceDir, outputDir / "assets")
}
// Generate index.html
val html = s"""<!DOCTYPE html>
|<html>
|<head><meta charset="UTF-8"><title>My App</title></head>
|<body>
| <canvas id="canvas"></canvas>
| <script type="module" src="main.js"></script>
|</body>
|</html>""".stripMargin
IO.write(outputDir / "index.html", html)
outputDir
}
SGE's sgePackageBrowser task is a production-grade example of browser
packaging. It performs these steps:
fullLinkJS to produce optimized JavaScriptmain.js to the output directoryassets/ subdirectoryassets.txt manifest listing every file in assets/
(so the app can discover available resources without directory listing)index.html that loads main.js as an ES moduleThe assets.txt manifest pattern solves a key browser limitation: there
is no way to list files in a directory via HTTP. The manifest file lets the
application enumerate available assets at runtime.
For development, serve the fastLinkJS output directory with any HTTP server.
The server must serve files with correct MIME types (especially
application/javascript for .js files and application/wasm for .wasm).
# After running fastLinkJS, serve the output directory
# Using Node.js http-server (install: npm install -g http-server)
cd target/scala-3.8.3/my-app-fastopt
http-server -c-1 -p 8080
# Or using Python (if allowed by project rules)
# python3 -m http.server 8080
For ESModule output, the server must support the application/javascript
MIME type for .js files served with import statements.
In one terminal, run sbt in watch mode:
sbt --client '~coreJS3/fastLinkJS'
This recompiles and re-links on every source change. In another terminal, run the HTTP server pointing at the output directory. Refresh the browser to pick up changes.
sbt --client 'browser/run'
This executes the Scala.js output via Node.js, not a browser. Node.js
does not have browser APIs (document, window, canvas, fetch for
relative URLs). Use sbt run only for code that is pure computation or
uses Node.js-compatible APIs.
To test in an actual browser, use the dev server approach described above or a test framework with browser integration (e.g., Playwright).
Scala.js does not support:
| Missing | Alternative |
|---|---|
java.io.File | fetch() API, scala.scalajs.js.typedarray |
java.net.Socket | fetch(), WebSocket, XMLHttpRequest |
Threads (java.lang.Thread) | scala.scalajs.js.Promise, Web Workers |
Reflection (Class.forName) | Explicit registration, @JSExportTopLevel |
System.exit() | Not applicable in browser context |
| Blocking I/O | Everything is async in the browser |
Use platform-specific source directories (src/main/scala-js/) to provide
browser-specific implementations of APIs that differ across platforms.
// Test linker config can differ from main
Test / scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.CommonJSModule)
}
Tests run on Node.js by default. For browser-based testing, configure a browser JS environment:
// project/plugins.sbt
libraryDependencies += "org.scala-js" %% "scalajs-env-selenium" % "1.1.1"
// build.sbt
Test / jsEnv := new org.scalajs.jsenv.selenium.SeleniumJSEnv(
new org.openqa.selenium.chrome.ChromeOptions()
)
SGE uses Playwright for browser smoke tests:
sgePackageBrowser produces the packaged outputThis validates the full pipeline: Scala.js compilation, linking, HTML wrapper, asset loading, and browser execution.
// project/plugins.sbt
addSbtPlugin("com.eed3si9n" % "sbt-projectmatrix" % "0.11.0")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0")
addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.10")
addSbtPlugin("com.kubuszok" % "sbt-multiarch-scala" % "0.1.2")
// build.sbt
val scala3 = "3.8.3"
val commonSettings = Seq(
scalaVersion := scala3,
scalacOptions ++= Seq("-deprecation", "-feature", "-no-indent")
)
lazy val core = (projectMatrix in file("core"))
.settings(commonSettings)
.settings(name := "my-game-core")
.jvmPlatform(scalaVersions = Seq(scala3))
.jsPlatform(scalaVersions = Seq(scala3), settings = Seq(
scalaJSUseMainModuleInitializer := true,
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) },
Compile / mainClass := Some("mygame.Main")
))
.nativePlatform(scalaVersions = Seq(scala3))
// Browser packaging task on the JS platform
lazy val coreJS3 = core.js(scala3)
# Development build
sbt --client 'coreJS3/fastLinkJS'
# Production build
sbt --client 'coreJS3/fullLinkJS'
# Run via Node.js (not a browser)
sbt --client 'coreJS3/run'
# Run tests
sbt --client 'coreJS3/test'
Cause: sbt-scalajs version too old for Scala 3.8.x Fix: Use sbt-scalajs >= 1.20.0 with Scala 3.8.x
Symptom: Blank page, console shows MIME type or CORS errors
Cause: Serving files without an HTTP server (file:// protocol) or wrong MIME types
Fix: Use an HTTP server. ES modules require text/javascript MIME type and
do not work over file:// due to CORS restrictions.
Symptom: Error when running via sbt run
Cause: sbt run uses Node.js, which has no DOM
Fix: Browser APIs are only available in an actual browser. Use sbt run
only for non-DOM code. Test DOM code via a dev server or Playwright.
Symptom: fetch("assets/texture.png") returns 404
Cause: Resource files not copied alongside JS output
Fix: Copy src/main/resources/ to the output directory. Use a packaging
task (see "sbt task for copying resources" above) or serve resources from a
separate directory with the correct URL mapping.
Symptom: main.js is several MB in development
Cause: fastLinkJS does not optimize — this is expected
Fix: Use fullLinkJS for production. Development size is not indicative
of final bundle size. Closure Compiler typically reduces output by 5-10x.
Symptom: Test failures when using ModuleKind.ESModule
Cause: Node.js test runner may not support ES modules by default
Fix: Use CommonJSModule for the test configuration:
Test / scalaJSLinkerConfig ~= {
_.withModuleKind(ModuleKind.CommonJSModule)
}
Symptom: Browser debugger shows compiled JavaScript, not Scala source
Cause: Source map file not served or not found
Fix: Ensure .js.map files are in the same directory as the .js files
and the HTTP server serves them. Source maps are generated by default for
fastLinkJS but not for fullLinkJS (enable with
scalaJSLinkerConfig ~= { _.withSourceMap(true) }).
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub kubuszok/multiarch-scala --plugin multiarch-scala