Viteless
Simon’s Guerilla guide to getting started with Scala JS.
It is assumed that coursier and scala-cli are on the path. i.e. that shell calls to
cs version
and
scala-cli -v
Behave reasonably and print their version in the shell you are using. If so… game on!
mkdir viteless && cd viteless && mkdir out && touch hello.scala
- Copy and paste into
hello.scala
the below.
//> using scala 3.4.2
//> using platform js
//> using dep org.scala-js::scalajs-dom::2.8.0
//> using dep com.raquo::laminar::17.0.0-M6
//> using jsModuleKind es
//> using jsModuleSplitStyleStr smallmodulesfor
//> using jsSmallModuleForPackage webapp
package webapp
import org.scalajs.dom
import org.scalajs.dom.document
import com.raquo.laminar.api.L.{*, given}
@main
def main: Unit =
renderOnDomContentLoaded(
dom.document.getElementById("app"),
interactiveApp
)
def interactiveApp =
val hiVar = Var("World")
div(
h1(
s"Hello ",
child.text <-- hiVar.signal
),
p("This is a simple example of a Laminar app."),
// https://demo.laminar.dev/app/form/controlled-inputs
input(
typ := "text",
controlled(
value <-- hiVar.signal,
onInput.mapToValue --> hiVar.writer
)
)
)
Run the below command in your terminal, adjusting the paths to your project
cs launch io.github.quafadas:live-server-scala-cli-js_3:0.0.12
A browser window should pop up at localhost:3000
with this hello world app.
Final step : change some code - observe browser refresh when linking completes.
That’s the TL;DR…
Goal
Can we replicate the vite “experience” of web development in scalajs using vite… without vite. Or NPM. Or node.
Basically, toss JS tooling in the bin :-).
After some time configuring node. And NPM. And vite. And then doing it all again in CI, I asked…
Wouldn’t it be more fun to… write our own frontend development server?
This is, in a way the natural evolution to this post.
Contraints
“replicating vite” is a big job. I might be stupid, but I ain’t that bloody stupid :-). We aren’t trying to replicate vite, with it’s big plugin ecosystem and support of whatever the latest frontend whizzbangery is these days. We’re trying to replicate vites experience for my scalaJS projects.
I claim that this is less stupid. YMMV.
Funnily enough though, once you break it down, each invidual piece is… not that bad…
Features
- insta-style application
- proxy requests to backend
- open webpage on start
- resolve references to JS eco-system
- serve website
- naively
- reloadably-on-change
If all this works, that is our definition of done.
1. Insta Style Application
I style things with LESS. It turns out, that this is built right in.
We will not be needing vite, to save ourselves from a script tag in our html. One down.
2. Proxy requests to backend
We’re in scala, right? A mythical land where just about everyone you trip over is secretly a backend ninja. Someone must have a prox… well hello.
Mostly, I copied and pasted code from there and poked it with a sharp stick until it did what I wanted.
3. open webpage on start
At least make it a challenge…
def openBrowserWindow(uri: java.net.URI): Unit =
println(s"opening browser window at $uri")
if Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) then
Desktop.getDesktop().browse(uri)
4. resolve references to JS eco-system
Finally! Something non-trivial…
The big observation here, is that one can resolve ESModules directly out of ES module syntax in Browser. See this post for more detail. ES Modules at link time. This capability is now in SBT, scala-cli and mill.
For the purposes of this excercise, it negates the need for a bundler. Instead, we can rely on the browser resolution of ES Modules. From a strategic perspective - sure we’re giving up vite. But we replace it with a ** Browser **. Vite is good software, sure, but there are leagues, and vite ain’t in the same league as Chrome or Safari. I never looked back.
5. serve website
Naively
There is a very simple approach, which is just serve straight out of javas simple http server.
$JAVA_HOME/bin/jwebserver -d c:/temp/helloScalaJs/out -p 8000
That server starts super fast, and it proves our concept to this point works, because it resolves the modules and we can visit it in browser. It’s here because firstly it’s an easy way to verify the steps to this point, and also, because it’s super useful for unit testing. It’s killer in combination with Playwright.
As part of a hot development loop however, it’s seriously lacking. We need to restart the app on every change - which is not the experience we are looking for.
Hot Reload
Well, we now come to the point. If do things the vite way, then we need to somehow track all the module dependancies, figure out which one has changed or is dependant, reload it and heaven knows what else. Vite seems to setup some heavy duty websocket comms to manage all this.
But at this point, we’re like 100 lines of blogpost in. Time to cheat.
for ((p: Path, h: Hash) <- modules)
yield link(rel := "modulepreload", href := s"${p}?hash=${h}")
Instead of all that, the proposal is to generate index.html
on the fly. We include in the header all the modules generated by the scala JS compiler as modulepreload
s - they are all aggressively downloaded by the browser when index.html
loads.
Module preloads have some nice properties. If we include, say, a query parameter and match that parameter, then the browser hits it’s cache when it goes to resolve the module a second time. The browser cache is fast. Proposal: file hash. If we change the hash, our (changed) module re-downloads. It means that there’s no “module ping pong” as browser traverses the module graph - it hits the cache every time, because we pre-loaded all the modules.
So reloading? Fast. Very, fast. And the difficult module resolution problems? All dealt with by your friendly neighbourhood browser.
To generate our index.html
, our dev server monitors file changes, and updates a MapRef[File, Hash]
. We use that MapRef
to generate the index.html
on demand. It appears natural, to request a page refresh (and a new index.html
) when we detect linker success.
The final thing we need to do is include in index.html
a script which refreshes the page when it recieves the right event from our dev server.
const sse = new EventSource('/api/v1/sse');
sse.addEventListener('message', (e) => {
const msg = JSON.parse(e.data)
if ('KeepAlive' in msg)
console.log("KeepAlive")
if ('PageRefresh' in msg)
location.reload()
});
To trigger a page refresh, we use server sent events.
case GET -> Root / "api" / "v1" / "sse" =>
val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive())
Ok(
keepAlive
.merge(refreshTopic.subscribe(10).as(PageRefresh()))
.map(msg => ServerSentEvent(Some(msg.asJson.noSpaces)))
)
Does it work?
It certainly seems to. See the below screenshot.
The “fat” scalaJS dependancy gets loaded out of memory in 9.88ms on page regfresh, which means page refresh is essentially instantaneous, once the linker completes.