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

  1. insta-style application
  2. proxy requests to backend
  3. open webpage on start
  4. resolve references to JS eco-system
  5. serve website
    1. naively
    2. 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.

Originall, I wanted to use module preloads. However, it's not possible to use module preloads in a way that is compatible with the browser cache - and we want the browser cache. Browser cache is fast. We want to use it.

What we do intead, is to provide each module with a hash of it's content. When the module is loaded, we check the hash. If the hash is different, we reload the module. This is a very simple approach, but it works. If we configure middleware correctly, then wqhen the browser comes to reload, it can send the ETag and Validity of the existing resource. If we match, then we simply send back a 304 and the browser uses the cached resource.

So reloading? Fast. Very, fast. And the difficult module resolution problems? All dealt with by your friendly neighbourhood browser. Even better, we can take advantage of a little knowledge of scala-js to preload the fat internal dependancies!

This is a big win, because the fat dependancies are the slowest to load and appear at the end of the module graph. I believe this change makes us faster than vite for non-trivial projects.

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.

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.