Scala.js lenses, frontends, and server side rendering with an AWS Lambda Scala backend
As a long time Java coder I’m finding that it’s a great time to start working with Scala. Scala 3 libraries are fun to work with and contextual abstractions feel natural once you’ve used them a few times. Unfortunately most Scala job postings available in Canada require a few years of professional Scala experience.
That puts Scala in the hobby language bucket for me but it’s so nice to work with that I want to try using it with some more serious side projects. AWS Lambda makes it cheaper to start and run a small side project and the recent JVM SnapStart feature reduces cold start latency.
I spun up an experimental project with a Scala.js frontend, a Scala AWS Lambda backend and the results were promising. Using Monocle for lenses, scala-js-snabbdom and ScalaTags I was able to make an interesting frontend UI that could also be rendered on the backend.
I haven’t done extensive performance testing.
The backend cold start times with SnapStart have been around 1 second measured with curl
which is good enough for my uses.
Response times after cold start with curl
have usually been between 100-200ms, but I will probably have to keep track of it as the logic and data access gets more involved.
For the frontend I stitched together snabbdom and ScalaTags with some of my own glue code. The Tyrian UI library or Slinky would probably be a better choice for a more serious project.
The RockTheJVM Fullstack Typelevel Stack course uses Tyrian to build a complete application. It’s on my list to work on in the future.
The code for this project is available on GitHub.
Building the Javascript bundle
I wanted this project to have shared logic and UI component code between the frontend and backend. I like server side rendering and that was also something that I wanted support for.
To setup the sbt project I ended up with 3 subprojects:
commonui
for the shared frontend and backend codebackend
for the backend code and AWS lambda packagefrontend
for the frontend code and browser JavaScript bundle
The sbt-crossproject created the commonui directory structure which saved me some time figuring out how to set it up. It took me a while to figure out that a cross-project contains sbt projects but the cross-project root is not an sbt project itself but it also makes a lot of sense once you use it.
- commonui/ - shared/ - src/main/scala/ - js/ - jvm/ - build.sbt - backend/ - src/main/scala/ - build.sbt - frontend/ - src/main/scala/ - build.sbt - package.json - build.sbt
As an sbt novice I probably made a few mistakes but the main points should be the same even if they’re hidden by a plugin:
- Compile the Scala code to Javascript with Scala.js
- Import the Scala.js Javascript to the
frontend
project - Use a web bundler Parcel.js to create browser JavaScript bundle and include any npm libraries required
Compiling Scala to Javascript
Compiling Scala.js projects is straightforward once you have the directory structure setup up properly.
The commonui
project is setup as a cross project so it can output JVM and JS code.
First the plugins have to be added to project/plugins.sbt
.
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0")
It’s tempting to put the code that uses ScalaJS dependencies in the commonui/js
subproject but for now I think it’s cleaner to restrict it to just the shared code.
lazy val root = project.in(file(".")) lazy val commonui = (crossProject(JSPlatform, JVMPlatform) in file("commonui")) .settings(libraryDependencies ++= Seq( "dev.optics" %%% "monocle-core" , "dev.optics" %%% "monocle-macro" ).map(_ % monocleVersion) )
The frontend
project is pure ScalaJS which allows it to import ScalaJS-only dependencies.
Using dependsOn
adds the commonui
library to the frontend
project
lazy val commonuiJS = commonui.js lazy val commonuiJVM = commonui.jvm lazy val circeDependencies = Seq(libraryDependencies ++= Seq( "io.circe" %%% "circe-core", "io.circe" %%% "circe-generic", "io.circe" %%% "circe-parser" ).map(_ % circeVersion)) lazy val frontend = (project in file("frontend")) .enablePlugins(ScalaJSPlugin) .dependsOn(commonuiJS) .settings( Compile / mainClass := Some("Main"), scalaJSUseMainModuleInitializer := true, libraryDependencies ++= Seq( "io.github.buntec" %%% "scala-js-snabbdom" % "0.1.0", "org.scala-js" %%% "scalajs-dom" % "2.4.0" ), circeDependencies)
With this setup the JS can be compiled with sbt frontend/fastLinkJS
for the development version or sbt frontend/fullLinkJS
for a minimized production version.
The Scala.js build documentation has more detailed information about these targets.
This produces JS code but it has to be bundled for the browser.
I heard you liked build systems, so I connected a build system to your build system
The general problem of connecting JS bundlers to non-JS build systems is a leaky abstraction jungle for me. Even when the bundler is just webpack it can be tough to map a pure webpack configuration to the equivalent abstraction in the build system. At the same time, it’s boring glue code that shouldn’t be repeated everywhere.
I wanted an easy off-ramp if I wanted to change bundlers so I connected the bundler manually. The Scala.js Vite sbt plugin might be a better option in general though.
Running sbt frontend/fastLinkJS
produces frontend/target/scala-3.2.1/frontend-fastopt
and sbt frontend/fastLinkJS
produces frontend/target/scala-3.2.1/frontend-opt
.
- frontend/ - target/ - scala-3.2.1/ - frontend-fastopt/ - main.js - main.js.map - frontend-opt/ - main.js - main.js.map
Looking at the Parcel.js build docs the HTML entry point seemed to be the easiest way to plug in the source. With the HTML entry point a content hash is generated and added to the bundle filename which makes deployment easier and is useful for having multiple versions live at the same time.
I added a hardcoded frontend/src/main/html/index.html
to do this but it should probably be dynamically generated to support both fastLinkJS
and fullLinkJS
.
As a bonus this means I can test the frontend code from a browser with using the parcel dev server.
<html> <script type="module" src="../../../target/scala-3.2.1/frontend-opt/main.js"></script> <link rel="stylesheet" href="https://unpkg.com/chota@latest"> <body> Parcel bootstrap <div id="snabbdom-container"> </div> </body> </html>
Parcel.js is installed by running npm install
in the frontend
directory.
It mostly works without configuration but adding the source
configuration to package.json
tells it where to find the HTML entry point without a command line argument.
{ "name": "cardzfrontend", "version": "1.0.0", "source": "src/main/html/index.html", "devDependencies": { "parcel": "^2.8.3", "parcel-reporter-clean-dist": "^1.0.4" } }
To run Parcel from sbt I added a Global
target to the root project.
Global / parcelJavascriptFiles := { (frontend / Compile / fullLinkJS).value Process("npx" :: "parcel" :: "build" :: Nil, file("frontend")) ! streams.value.log }
Parcel.js will produce a bundle in frontend/dist
.
- frontend/ - dist/ - index.e1b1309e.js - index.html
Adding a .parcelrc
and parcel-reporter-clean-dist
will clean out old versions.
{ "extends": ["@parcel/config-default"], "reporters": ["...", "parcel-reporter-clean-dist"] }
I copied over the bundle filename with the hash over to the backend so that it can be included in the backend html. The bundle itself will be served from S3.
Global / frontendJavascriptFiles := { parcelJavascriptFiles.value val javascriptFiles = FileTreeView.default.list(Glob(Paths.get("frontend").toAbsolutePath) / "dist" / "index*.js") val filenames = javascriptFiles.collectFirst { case (path, attributes) => path.toAbsolutePath.getFileName.toString } filenames }
Adding this as a Global
target makes it easier to use the target in the backend
project but maybe there’s a better way?
lazy val backend = (project in file("backend")) .dependsOn(commonuiJVM) .enablePlugins(JavaAppPackaging) .settings( topLevelDirectory := None, libraryDependencies ++= Seq( "com.lihaoyi" %%% "scalatags" % "0.12.0", "com.amazonaws" % "aws-lambda-java-core" % "1.2.2", "com.amazonaws" % "aws-lambda-java-events" % "3.11.1", "io.github.crac" % "org-crac" % "0.1.3" ), circeDependencies, Compile / resourceGenerators += Def.task { val file = (Compile / resourceManaged).value / "frontend" / "frontend.properties" val filename = (Global / frontendJavascriptFiles).value.head val contents = s"frontend.javascript.entrypoint=$filename" IO.write(file, contents) Seq(file) }.taskValue )
This produces a frontend.properties
file that we will read in the backend
project.
frontend.javascript.entrypoint=index.e1b1309e.js
AWS Lambda backend
We can now serve a basic index page and reference the generated bundle (deployment will come later). For some basic CSS styling, I included the chota CSS framework.
The lambda handler class just has to implement the RequestHandler
interface.
The class name will added to the deployment configuration later on.
import com.amazonaws.services.lambda.runtime.Context import com.amazonaws.services.lambda.runtime.RequestHandler import com.amazonaws.services.lambda.runtime.LambdaLogger import com.amazonaws.services.lambda.runtime.events import com.amazonaws.services.lambda.runtime.events.{APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent} import com.slopezerosolutions.scalatags.ScalaTagsBuilder import scalatags.Text.all.* import scala.jdk.CollectionConverters.MapHasAsJava import scala.io.Source import org.crac.Resource import org.crac.Core import scalatags.Text class LambdaHandler extends RequestHandler[APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent], Resource { private val frontendProperties = new FrontendProperties() override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = { gamePage } private def gamePage = { val htmlOutput = html( head( script(raw(s"""var apiBaseUrl="${frontendProperties.apiBaseUrl}";""")), script(attr("type") := "module", src := entryPointUrl), link(rel := "stylesheet", href := "https://unpkg.com/chota@latest") ), body() ).toString val event = new APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(Map("content-type" -> "text/html").asJava) .withBody(htmlOutput) event } }
For this basic page only a few dependencies are required.
libraryDependencies ++= Seq( "com.lihaoyi" %%% "scalatags" % "0.12.0", "com.amazonaws" % "aws-lambda-java-core" % "1.2.2", "com.amazonaws" % "aws-lambda-java-events" % "3.11.1", "io.github.crac" % "org-crac" % "0.1.3" )
To package up everything, I’m using the sbt-native-packager plugin and its universal
target.
This will produce a zipped directory of jar files.
Some articles online recommend a deployment that merges all the jar files fatJar style but this approach worked well and it’s nice to see all the dependencies packaged separately in the directory.
The resulting project/plugins.sbt
looks like this.
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.13.0") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.4")
Because I now need to plug the sbt build system results into the Serverless framework build/deploy system, I added another target to output the artifact path to a json file.
lazy val lambdaPackage = taskKey[Unit]("Lambda package") lambdaPackage := { val packageZip = (backend / Universal / packageBin).value val file = backend.base / "target" / "universal" / "lambda.json" val basePath = root.base.getAbsoluteFile val zipFile = basePath.relativize(packageZip).get.toString val contents = s"""{"artifact":"${zipFile}"}""" IO.write(file, contents) () }
This writes to backend/target/universal/lambda.json
{"artifact":"backend/target/universal/backend-0.1.0-SNAPSHOT.zip"}
If you want to see how this gets plugged in to the deploy process, skip ahead. For a change of pace, I’ll move on to client and server side rendering.
Client and server side rendering
ScalaTags is an awesome server side rendering library, and scala-js-snabbdom is an awesome client side virtual dom library. Even for a side project, server side rendering has enough benefits that I would use another language to get it.
Fortunately projects like Tyrian have server side rendering support. But when I took a look at the APIs for ScalaTags and scala-js-snabbdom it looked like an interesting exercise to link them together by hand. It looks like a similar approach could work with Slinky, but it might require a separate class for backend components.
The APIs are fairly similar, the main difference is the way dom attributes and event handlers are handled.
In scala-js-snabbdom, event handlers receive org.scalajs.dom.Event
s but the backend doesn’t need to know about these events.
ScalaTags can render inline event handlers but I just need the plain DOM in the initial render, scala-js-snabbdom can handle the events when it loads.
So I just need a common interface that outputs DOM elements, and a different implementation for ScalaTags and scala-js-snabbdom. This basically boils down to something similar to a Visitor. I also found out that the Tagless final pattern is similar with better typechecking properties.
This case is just DOM elements all the way down so only one return type is needed but Scala has some nice syntax for this type of use case.
For the interface I just added a method for each tag that I needed, with some overloads for convenience.
package com.slopezerosolutions.dombuilder trait DomBuilder[T] { def div(domAttributes: DomAttributes, contents: String | List[T]): T def div(contents: String | List[T] ): T = { div(DomAttributes.empty, contents) } def button(domAttributes: DomAttributes, contents: String | List[T]): T def input(domAttributes: DomAttributes): T }
scala-js-snabbdom has a finer grained separation between different types of DOM attributes, so it was easier to use a similar level of separation in the interface layer.
object DomAttributes { val empty = DomAttributes() } case class DomAttributes(props: Map[String,String] = Map(), attributes: Map[String,String] = Map(), handlers: Map[String, Any => Unit] = Map())
Implementing the interface is straightforward so I’m only including the ScalaTags implementation. For the ScalaTags implementation the event handlers are not rendered.
class ScalaTagsBuilder extends DomBuilder[ConcreteHtmlTag[String]] { def div(domAttributes: DomAttributes, contents: String | List[ConcreteHtmlTag[String]]): ConcreteHtmlTag[String] = { contents match { case text: String => scalatags.Text.all.div(modifiers(domAttributes): _*)(text) case list: List[ConcreteHtmlTag[String]] => scalatags.Text.all.div(modifiers(domAttributes): _*)(list) } } def button(domAttributes: DomAttributes, contents: String | List[ConcreteHtmlTag[String]]): ConcreteHtmlTag[String] = { contents match { case text: String => scalatags.Text.all.button(modifiers(domAttributes): _*)(text) case list: List[ConcreteHtmlTag[String]] => scalatags.Text.all.button(modifiers(domAttributes): _*)(list) } } override def input(domAttributes: DomAttributes): ConcreteHtmlTag[String] = { scalatags.Text.all.input(modifiers(domAttributes): _*) } private def modifiers(domAttributes: DomAttributes): Array[Modifier] = { val modifiers = ArrayBuffer[Modifier]() for((key,value) <- domAttributes.props) { modifiers += (attr(key) := value) } for ((key, value) <- domAttributes.attributes) { modifiers += (attr(key) := value) } modifiers.toArray } }
In Java the implementation would be similar but using this interface would be verbose. But using Scala 3’s given and using clauses and the import trick from Tagless final the abstraction is mostly invisible.
given
and using
pass the implementation through the call stack and import
removes the references to the builder implementation when calling the interface methods.
object EnemyComponent { def mainView[T](enemy: Enemy, children: T*)(using domBuilder: DomBuilder[T]): T = { import domBuilder.* div(DomAttributes(attributes = Map("class" -> "card")), List( div(s"Enemy ${enemy.id}"), div(s"Health: ${enemy.health}"), div(children.toList) )) } } case class Enemy(id: String, health: Int)
When EnemyComponent.mainView
is called and a given
DomBuilder
is in scope, it will automatically be passed in.
In the backend we use given domBuilder: DomBuilder[Text.TypedTag[String]] = new ScalaTagsBuilder()
.
In the frontend we can use given domBuilder: DomBuilder[VNode] = new SnabDomBuilder()
.
Now we have a way to render the same component code on the server side and the client side, we just need a way to handle frontend state and events.
Virtual DOM and contexts
Virtual DOM libraries are my preferred way to build frontends but a lot of frameworks optimize for the global context. This makes sense for a lot of applications but I’ve also run into cases where local contexts are useful. In React this ends up in useState calls but with Scala it looked like there should be a more functional way to do it.
I started with a basic Redux like store, it stores a mutable reference to one object that should be immutable. After the mutable reference is updated it is published to all subscribers.
package com.slopezerosolutions.dombuilder import monocle.macros.GenLens import monocle.Iso class RootViewContext[S](var rootContext: S) { private var subscribers: Vector[(RootViewContext[S])=>Unit] = Vector() def updateContext(update: S => S): Unit = { rootContext = update(rootContext) publish() } def publish(): Unit = { for (subscriber <- subscribers) { subscriber.apply(this) } } def viewContext: ViewContext[S, S] = { ViewContext(global = rootContext, local = rootContext, updateGlobal = updateContext, updateLocal = updateContext) } def subscribe(subscriber: RootViewContext[S] => Unit): Unit = { subscribers = subscribers :+ subscriber } }
After that I added a way to “zoom” into a section of the store using a Lens
or a Prism
.
The Prism
zooms in to a field that could store different subclass instances (or any type where the instance has multiple cases).
This is useful for supporting a section of the page that could change to multiple different views.
I have a player name entry section that changes into a game view section after the player’s name is entered.
If the ui switches from the player name section to the game view section and then sends an update to the player name section right after the Prism
will safely ignore that update.
Since the Prism
handles the case where the subclass instance isn’t there anymore, the instance is passed in when zooming in to avoid introducing Option
.
case class ViewContext[R, C](global: R, local: C, updateGlobal: (R => R) => Unit, updateLocal: (C => C) => Unit) { def zoomInto[Z](zoom: Lens[C, Z]): ViewContext[R, Z] = { val zoomedContext = zoom.get(local) def childUpdater(updateChild: (Z => Z)): Unit = { updateLocal(zoom.modify(updateChild)) } copy[R,Z]( local = zoomedContext, updateLocal = childUpdater ) } def zoomOptional[Z](zoomOptional: Optional[C,Z], zoomedLocal: Z): ViewContext[R, Z] = { def optionalChildUpdater(updateChild: (Z => Z)): Unit = { updateLocal(zoomOptional.modify(updateChild)) } copy[R, Z]( local = zoomedLocal, updateLocal = optionalChildUpdater ) } def update(updater: C => C): Unit = { updateLocal(updater) } }
It turns out this was enough to implement the event handling that I needed.
What is your name?
At the start of the game the player enters their name.
To implement this I created a global context that has a slot for the currently active page “section”.
object AppContext { val viewContext: Lens[AppContext, AppSection.Context] = GenLens[AppContext](_.viewContext) val playerName: Lens[AppContext, Option[String]] = GenLens[AppContext](_.playerName) } case class AppContext(eventAdapter: EventAdapter, viewContext: AppSection.Context, uuidGenerator: () => String, playerName: Option[String] = None, gameServiceOption: Option[GameService] = None)
The AppSection
is an empty base class that handles dispatching rendering to subclasses.
It’s a primitive version of a frontend router.
When it renders a section, it also “zooms in” to that section’s context.
object AppSection { abstract class Context val defaultContext = new Context{} def mainView[T](context: ViewContext[AppContext, AppContext])(using domBuilder: DomBuilder[T]): T = { import domBuilder._ val local = context.local local.viewContext match { case sectionContext: GameView.Context => { GameView.mainView( context.zoomOptional(AppContext.viewContext.andThen(GameView.gameContext), sectionContext) ) } case sectionContext: NameEntry.Context => { NameEntry.mainView(context.zoomOptional(AppContext.viewContext.andThen(NameEntry.context), sectionContext)) } case _ => div(DomAttributes(props = Map("data-error" -> "Unhandled ui context")), List()) } } }
The name entry section stores the state of the text input in the context.
When the Enter name
button is clicked, it takes the player’s name and stores it in the global context.
context.updateLocal(playerName.modify(_ => input))
updates the player’s name in the local context .
context.updateGlobal(AppContext.playerName.modify(_ =>; Some(local.playerName)))
updates the player’s name in the global context so it can be used by other parts of the app.
Both methods end up updating the same mutable reference in the RootViewContext
, but hiding the Lens
calls behind updateLocal
is useful.
Because playerName
is a Monocle Lens
, .modify
builds a new function that performs the immutable update.
Introduction to Optics is a good introduction to the Monocle Lens library.
Each update call will update the root context and publish the updated state to any subscribers.
After the player enters their name and that change is published, context.updateGlobal(AppContext.viewContext.modify(_ => GameView.createNewGame(global)))
switches the section to the GameView
section.
object NameEntry { case class Context(playerName: String = "") extends AppSection.Context val context = Prism.partial[AppSection.Context, Context] { case c: Context => c }(identity) val playerName: Lens[Context, String] = GenLens[Context](_.playerName) def mainView[T](context: ViewContext[AppContext, Context])(using domBuilder: DomBuilder[T]): T = { import domBuilder._ val local = context.local val global = context.global div(List( div(List(input(DomAttributes(props = Map("name" -> "playerName", "value" -> local.playerName), handlers = Map("change" -> global.eventAdapter.textInputAdapter((input) => { context.updateLocal(playerName.modify(_ => input)) })) )))), div(List( button(DomAttributes(handlers = Map("click" -> global.eventAdapter.clickAdapter(() => { context.updateGlobal(AppContext.playerName.modify(_ => Some(local.playerName))) context.updateGlobal(AppContext.viewContext.modify(_ => GameView.createNewGame(global))) }))), "Enter name"))) )) } }
There’s one other detail here for server-side-rendering.
I want to hide the org.scalajs.dom.Event
type from the backend code.
To do that I made the DOM event handlers Any => Unit
so they can accept any type.
The problem is that I now need a place to handle org.scalajs.dom.Event
on the frontend.
To do this I added an EventAdapter
interface that has a do-nothing implementation in the backend and a useful implementation in the frontend.
trait EventAdapter { def textInputAdapter(handler: (String) => Unit): (Any => Unit) = { (any: Any) => () } def clickAdapter(handler: () => Unit): (Any => Unit) = { (any: Any) => () } }
class SnabDomEventAdapter extends EventAdapter { override def textInputAdapter(handler: (String) => Unit): (Any => Unit) = { (event) => event match { case inputEvent: dom.Event => { inputEvent.target match { case e: HTMLInputElement => { handler(e.value) } } () } } } override def clickAdapter(handler: () => Unit): (Any => Unit) = { (event) => event match { case inputEvent: dom.MouseEvent => { handler() } } } }
After adding this to the global AppContext
it can be accessed by the views.
handlers = Map("change" -> global.eventAdapter.textInputAdapter((input) => { context.updateLocal(playerName.modify(_ => input)) }))
The question now is how this setup is initialized and how updates are rendered.
Most of the initialization is constructing the RootViewContext
and then using scala-js-snabbdom
to patch
in DOM updates.
For proper server-side-rendering, you would want to render the same context in the frontend and the backend. This would require passing some JSON representing the context from the backend to the frontend then using that to initialize the context. I skipped this part since the page is small and I wouldn’t notice the difference at this point.
The DOM update is provided by a RootViewContext
subscriber.
It renders the updates and passes them to scala-js-snabbdom.
scala-js-snabbdom requires you to pass in the results of the previous patch
call to work properly so there’s some extra code to handle that case.
object Main { def main(args: Array[String]): Unit = { val container = dom.document.getElementById("snabbdom-container") val patch = init(Seq(Attributes.module, Classes.module, Props.module, Styles.module, EventListeners.module, Dataset.module)) given domBuilder: DomBuilder[VNode] = new SnabDomBuilder() var containerVnode: Option[VNode] = None val rootViewContext = RootViewContext(AppContext(new SnabDomEventAdapter(), NameEntry.Context(), () => js.Dynamic.global.crypto.randomUUID().asInstanceOf[String], gameServiceOption = Some(new FrontendGameService(new FrontendConfiguration())) )) rootViewContext.subscribe((root) => { val context = root.viewContext val vnodes = h("div", VNodeData(props = Map("id" -> "snabbdom-container")), Array[VNode](AppSection.mainView(context))) containerVnode = containerVnode match { case Some(vNode) => { Some(patch(vNode, vnodes)) } case None => { Some(patch(container, vnodes)) } } }) rootViewContext.publish() } }
Now that we know what our name is, it’s time to play the game.
Gameplay
When the game starts the player sees their current hand of cards, a view of the current enemies and their health. They have attack cards that can attack enemies and heal cards that can heal their health points.
The hand of cards is rendered as some divs. When the player selects a card the card goes into the selected card box to give you more options for how to play it.
def selectCard(card: Card): Context => Context = { selectedCard.replace(Some(card)) }
Instead of removing the selected card from the hand, it was more convenient to filter out the selected card from the cards in hand.
If another piece of code wants to deactivate the selected card, it can just set it to None
instead of replacing the card into the hand of cards.
div(DomAttributes(attributes = Map("class" -> "row")), List( div(DomAttributes(attributes = Map("class" -> "col bd-dark")), local.cards.toList.filterNot(local.selectedCard.isDefined && _.id == local.selectedCard.get.id).map((card) => GameCard.mainView(card, _ => { context.update(selectCard(card)) }))), div(DomAttributes(attributes = Map("class" -> "col")), List(local.selectedCard match { case Some(card) => GameCard.mainView(card, _ => { context.update(selectedCard.replace(None)) }) case None => div("Select a card") })) ))
The card view renders attack cards and heal cards using different pattern match cases.
object GameCard { def mainView[T](card: Card, cardClickHandler: Any => Unit = _ => ())(using domBuilder: DomBuilder[T]): T = { import domBuilder._ card match { case attackCard: AttackCard => div( DomAttributes(attributes = Map("class" -> "card bd-dark"), handlers = Map("click" -> cardClickHandler)), List( div(s"Attack card: ${attackCard.id}"), div(s"Attack points: ${attackCard.attackPoints}") ) ) case healCard: HealCard => div( DomAttributes(attributes = Map("class" -> "card bd-dark"), handlers = Map("click" -> cardClickHandler)), List(div(s"Heal card: ${healCard.id}"), div(s"Adds ${healCard.healPoints} points of health")) ) } } }
After picking an attack card the player can pick the enemy to attack.
When an attack card is selected the enemies will render with an Attack
button so that they can be targeted.
The attack button should be extracted to another method, but it was nice to prototype this behavior inline.
EnemyComponent.mainView(enemy, local.selectedCard match { case Some(card@AttackCard(id, attackPoints)) => button( DomAttributes(attributes = Map("class" -> "button error"), handlers = Map("click" -> (_ -> { context.update( playCard(card) .andThen(attackEnemy(enemy, card)) .andThen(resolveKilledMonsters) ) })) ), "Attack") case _ => div("") } )
After attacking an enemy their health is reduced, if the enemy health goes below zero the enemy is removed.
def playCard(card: Card): Context => Context = { selectedCard.replace(None).andThen(cards.modify(_.filterNot(_.id == card.id))) } def attackEnemy(enemy: Enemy, card: AttackCard): Context => Context = { // eachEnemy is a Monocle Traversal that updates every enemy eachEnemy.modify((otherEnemy) => if otherEnemy.id == enemy.id then Enemy.health.modify(_ - card.attackPoints)(otherEnemy) else otherEnemy) } def resolveKilledMonsters(context: Context): Context = { val killedEnemies = context.enemies.filter(_.health <= 0) context.copy( enemies = context.enemies.filterNot(_.health <= 0), enemiesKilled = context.enemiesKilled + killedEnemies.length ) }
The card is then removed from the player’s hand and the player can then select another card.
After picking a heal card they can heal themselves. This works basically the same way the attack code does but adds health instead of subtracting it.
Drawing a new hand of cards
The player has played all the cards in their hand, an HTTP call is made to draw a new hand of cards. For some games every player action should be sent to the server and the server should manage the game logic. To stub out that case I added an HTTP API to the lambda to draw more cards.
override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = { if(input.getPath == "/game" && input.getHttpMethod == "GET"){ return gamePage } else if (input.getPath == "/cards/draw" && input.getHttpMethod == "POST"){ return drawCards() } notFoundPage(input.getPath) }
private def drawCards() = { val random = new Random() val cards = (1 to 5).map( _ => { random.nextInt(2) match { case 0 => AttackCard(java.util.UUID.randomUUID().toString, random.nextInt(5) + 1) case 1 => HealCard(java.util.UUID.randomUUID().toString, random.nextInt(3) + 1) } } ) val event = new APIGatewayProxyResponseEvent() .withStatusCode(200) .withHeaders(Map("content-type" -> "application/json").asJava) .withBody(cards.asJson.toString) event }
Again we have to stub out the backend because the frontend is using scala-js-dom Fetch to make the call.
I made an interface in commonui
and Option
it in the global AppContext
.
Since I’m expecting service calls to be rare compared to event handlers, Option
makes it clear that the implementation might not be available.
import scala.concurrent.Future trait GameService { def drawCards(): Future[Option[Vector[Card]]] }
case class AppContext(eventAdapter: EventAdapter, viewContext: AppSection.Context, uuidGenerator: () => String, playerName: Option[String] = None, gameServiceOption: Option[GameService] = None)
Then the implementation is added to the frontend
project.
import org.scalajs.dom import org.scalajs.dom.{Request, RequestInit} import org.scalajs.dom.experimental.HttpMethod import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.Future import scalajs.js.Thenable.Implicits.* import io.circe.* import io.circe.generic.auto.* import io.circe.parser.* class FrontendGameService(frontendConfiguration: FrontendConfiguration) extends GameService { override def drawCards(): Future[Option[Vector[Card]]] = { val request = new Request(frontendConfiguration.apiUrl("cards/draw"), new RequestInit { method = dom.HttpMethod.POST }) val response: Future[Either[Error, Vector[Card]]] = for { response <- dom.fetch(request) bodyText <- response.text() } yield { decode[Vector[Card]](bodyText) } response.map(_.toOption) } }
The view code calls this API if a GameService
is present in the global context.
Because update
only accepts a function that performs an update, we don’t have the actual result of that update.
That might be useful to add, but in that case it can only return an Option
because when the update is applied, the section might have switched.
To work around this, I checked the current local state to see if the player’s hand would be empty after playing a card.
Since the GameService
returns a Future
, the update
with the new cards happens after the HTTP call completes.
Prism
will prevent this update in case it happens after the section is changed.
One issue is if the player can switch between multiple game instances, this call could complete later and add the cards to a different instance.
In that case it might be useful to put a guard in the Prism
that also checks the game instance id.
handlers = Map("click" -> (_ -> { context.update( playCard(card) .andThen(attackEnemy(enemy, card)) .andThen(resolveKilledMonsters) ) if (isEmptyAfterPlaying(card, local)) { global.gameServiceOption.map( gameService => { gameService.drawCards().map { case Some(drawnCards) => context.update(cards.modify(_ ++ drawnCards)) case None => () } }) } }))
That completes the game with some non-trivial use cases stubbed out. Time to ship it.
Deploying with Serverless Framework
To deploy this package I used the Serverless Framework.
To install serverless framework I added a package.json
file.
node.js
was already installed using nvm.
{ "name": "test-scala-serverless", "version": "1.0.0", "devDependencies": { "serverless": "^3.30.1", "serverless-s3-sync": "^3.1.0" } }
Running npm install
installs the framework in the project.
To configure it, I added a serverless.yml
file.
service: aws-test-scala-serverless frameworkVersion: '3' provider: name: aws runtime: java11 region: us-west-2 package: artifact: ${file(./backend/target/universal/lambda.json):artifact} functions: api: handler: LambdaHandler events: - http: ANY /{paths+} snapStart: true environment: ASSETS_BASE_URL: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsUrlBase} API_BASE_URL: ${file(./deploy-config.${opt:stage, 'dev'}.json):apiBaseUrl} custom: s3Sync: - bucketName: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsBucketName} bucketPrefix: ${file(./deploy-config.${opt:stage, 'dev'}.json):assetsBucketPrefix} localDir: frontend/dist plugins: - serverless-s3-sync
Back to backend/target/universal/lambda.json
.
It had a reference to the universal
package that sbt built with .jar
files of all the backend code and dependencies.
{"artifact":"backend/target/universal/backend-0.1.0-SNAPSHOT.zip"}
This snippet points serverless to the zip file, and skips the default Serverless Framework packaging step.
package: artifact: ${file(./backend/target/universal/lambda.json):artifact}
The frontend code is deployed to an S3 hosted static website.
Serverless Framework needs to know what S3 bucket to upload to and the backend needs to know where the JS bundles are hosted.
This config is set in the deploy-config.dev.json
file.
Update: The S3 site also needs a CORS configuration. I couldn’t find the original source I used for the configuration but this post seems to have the right steps. The CloudFront configuration is a pain, but it provides caching and better configuration options vs S3 direct hosting.
{ "assetsBucketName": "YOURBUCKET", "assetsBucketPrefix": "testscalaserverless/dev/assets", "assetsUrlBase": "https://www.example.com/testscalaserverless/dev/assets", "apiBaseUrl": "https://www.example.com/testscalaserverless/dev" }
The serverless-s3-sync
plugin is used to copy the JS bundle to S3 during the deploy phase.
To be safe it might be better to run the S3 upload before deploying to AWS Lambda.
If you’re following along, you would have to set up AWS credentials with a named profile.
To deploy, run sbt lambdaPackage
to build the package, and npx serverless
to deploy to AWS Lambda and S3.
sbt lambdaPackage
npx serverless deploy --aws-profile MYAWSPROFILENAME
Serverless will create a new REST API Gateway and print out the url on the command line. Creating a custom domain for the API is a whole other process.
AWS Lambda SnapStart
Without SnapSnart the cold start response times are around 2 seconds measured by curl
.
This project doesn’t have a lot of dependencies, so most of it is probably extracting .jar
files and loading classes.
With SnapStart the cold start response times came down to around 1 second.
To implement SnapStart, add the CraC dependency, register the handler with Core.getGlobalContext.register
, and implement the afterRestore
and beforeCheckpoint
methods.
libraryDependencies ++= Seq( "io.github.crac" % "org-crac" % "0.1.3" )
class LambdaHandler extends RequestHandler[APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent], Resource { Core.getGlobalContext.register(this) override def handleRequest(input: APIGatewayProxyRequestEvent, context: Context): APIGatewayProxyResponseEvent = { if (input.getPath == "/game" && input.getHttpMethod == "GET") { return gamePage } else if (input.getPath == "/cards/draw" && input.getHttpMethod == "POST") { return drawCards() } notFoundPage(input.getPath) } def afterRestore(context: org.crac.Context[? <: org.crac.Resource]): Unit = { } def beforeCheckpoint(context: org.crac.Context[? <: org.crac.Resource]): Unit = { gamePage } }
beforeCheckpoint
will run when you deploy, saving a memory snapshot that will be used during cold starts.
I wanted to preload most of the libraries that would be used, so I just ran the gamePage
method.
Tuning AWS Lambda JVM SnapStart goes in detail into how to do this with different Java frameworks and proper response time measurements.
Takeaways
I’m pretty happy with the results so far, the main thing I will probably try next is using Tyrian and the Indigo HTML game engine. Lessons learned:
- A lot can be done on the browser with Scala.js and pure Scala.js libraries
- I wish I had found out about ScalaTags and scala-js-snabbdom sooner
given
andusing
clauses can clean up abstractions that would normally be verbose and cumbersome- Monocle lenses enable interesting possibilities for immutable data and user interfaces
- AWS Lambda SnapStart makes JVM Scala lambda cold starts quick enough for a lot of use cases
- Being able to pass build targets between sbt sub-projects made it easier to pass JS bundle information around
- Serverless framework is a nice way to deploy Scala lambdas with a bit of configuration