Web Servers with Cask
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Scala 3.10 (3.10.0, released 2026-10-12) on JDK 26, with Cask 0.11.3 and Scalatags 0.13.1 (both from Maven
Central). The server below was compiled and started with Scala CLI on 2026-10-16 on localhost, a free port, and was queried with curl
and with a small JDK websocket client; every response shown is real. Cask itself is plain Scala 3 library code, so what is shown works the
same on Scala 3.3 LTS and 3.9 LTS. The Cask 0.11.3 artifact is built with Scala 3.3.4, which is why it is usable from all three lines. The
Scala Toolkit guide this page derives from uses Cask 0.10.2; the differences that matter are collected in Where the Toolkit guide is outdated.
Cask is an HTTP micro-framework from the Li Haoyi ecosystem, the same family as OS-Lib and uPickle. It runs on the Undertow web server, and its
selling point is a very small surface: a route is a method with an annotation that names the path, and the method parameters are filled from the
request. It is blocking and synchronous by design (no asynchronous endpoints), which makes it a good fit for small services, internal tools and
learning HTTP. Cask is not part of the Scala Toolkit dependency itself; the Toolkit guides use it as the web-server example and ask you to add
it separately. The Java counterpart of "a server in one file" is the JDK’s com.sun.net.httpserver.HttpServer or a framework such as Javalin or
Spring Boot; a Groovy script can serve pages with
Groovlets. Building the project around it is covered on
Java Build & Tooling and
sbt & Build Tools.
The scenario is the trailhead service: a server that knows about a few trails and answers questions about them. The other Toolkit libraries combine naturally with it: JSON is covered on JSON with uPickle, calling a server from Scala on HTTP Client with sttp, and files on Files & Processes with OS-Lib.
Getting Cask
With Scala CLI the dependency is one directive, as described on Scala CLI:
//> using dep com.lihaoyi::cask:0.11.3
The :: separator selects the Scala 3 build, cask_3. The Toolkit guide writes com.lihaoyi::cask::0.10.2, with a second ::; that form asks for a
Scala.js or Scala Native build and is not needed (Cask is JVM only). In sbt (not run here, since this page uses Scala CLI throughout):
libraryDependencies += "com.lihaoyi" %% "cask" % "0.11.3"
fork := true
fork := true runs the server in its own JVM so that sbt can stop it with Ctrl-C and reload it. A resolved Cask 0.11.3 brings
undertow-core 2.3.18.Final, uPickle 4.0.2 (Cask uses it for JSON; the Toolkit’s newer uPickle wins if both are present) and cask-util.
A first server
A Cask application is an object that extends cask.MainRoutes. It provides a main method that starts Undertow, so the object is the program. Routes are
methods annotated with the HTTP method and the path. The last line, initialize(), registers the annotated methods; forgetting it starts
a server with no routes. All the later examples are more routes inside this same object:
//> using scala 3.10.0
//> using dep com.lihaoyi::cask:0.11.3
object Trailhead extends cask.MainRoutes:
override def port: Int = sys.props.getOrElse("trailhead.port", "8080").toInt
override def debugMode: Boolean = false
@cask.get("/")
def index(): String = "Trailhead is up"
initialize()
Run it with scala run . --java-prop trailhead.port=53767 (--java-prop sets a system property for the program). The server listens on localhost by default, the
default port is 8080 and host, port, debugMode and verbose are members of cask.main.Main that you can override. A request answers:
$ curl -i localhost:53767/
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 15
Trailhead is up
(The Date and Connection headers are left out here and below.) On JDK 24 and later the first start also prints four WARNING lines about
sun.misc.Unsafe::objectFieldOffset and scala.runtime.LazyVals$. They come from the Scala 3.10 standard library and are not an error; they are
discussed on Versions, Compatibility & JDKs.
debugMode defaults to true, and in that mode Cask returns the exception’s stack trace in the body of a 400 or 500 response. That is useful
on a laptop and a leak in production, so the examples switch it off, and the failures shown below are the quiet ones.
The annotation classes (cask.get, cask.post, cask.put, cask.delete, cask.patch, cask.options, cask.postForm, cask.postJson,
cask.staticFiles, cask.staticResources, cask.websocket) are ordinary Scala annotations that extend Cask’s Endpoint type; the
annotation itself does the routing, which is why a route does nothing until initialize() collects them. See
Annotations & Visibility for how annotations work in general.
Static files
@cask.staticFiles serves files from a directory. The method returns the directory, and the rest of the URL after the annotation’s path is appended
to it. With a resources/hello.html file next to the program:
@cask.staticFiles("/static")
def staticEndpoint(): String = "resources"
$ curl -i localhost:53767/static/hello.html
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 116
<!doctype html>
<html>
<head><title>Trailhead</title></head>
<body><h1>Welcome to Trailhead</h1></body>
</html>
The directory is relative to the process’s working directory (the Toolkit guide uses src/main/resources for sbt). A missing file is a 404
with an empty body:
$ curl -i localhost:53767/static/nope.html
HTTP/1.1 404 Not Found
Content-Type: text/html
Content-Length: 0
@cask.staticResources is the variant that reads from the class path (files packaged inside the JAR), with the same shape: the method
returns the resource directory, with "." meaning the root of the resources. Use it when the application is shipped as a single JAR, because a
relative staticFiles directory will not exist wherever the JAR is run.
Dynamic routes and path segments
An endpoint that returns a String is sent as text/plain. A :name in the path is a path segment, and Cask binds it to the method
parameter with the same name; the parameter’s type is the conversion (Int, Long, Double, Boolean and String are supported):
case class Trail(name: String, lengthKm: Double, difficulty: String)
private val trails = List(
Trail("Pico Loop", 9.5, "moderate"),
Trail("Lake Walk", 3.2, "easy"),
Trail("Ridge Traverse", 14.0, "hard")
)
@cask.get("/trails/:name")
def trail(name: String): cask.Response[String] =
trails.find(_.name.equalsIgnoreCase(name.replace('-', ' '))) match
case Some(t) => cask.Response(s"${t.name}: ${t.lengthKm} km, ${t.difficulty}")
case None => cask.Response(s"No trail called $name", statusCode = 404)
$ curl -i localhost:53767/trails/pico-loop
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 27
Pico Loop: 9.5 km, moderate
$ curl -i localhost:53767/trails/Nowhere
HTTP/1.1 404 Not Found
Content-Type: text/plain; charset=utf-8
Content-Length: 23
No trail called Nowhere
cask.Response[T] is the manual way to build a response: data, statusCode, headers (a Seq[(String, String)]) and cookies. Returning a
plain String is the shortcut for Response(data, 200). In a real service the case class belongs outside the object, but inside it keeps
this page’s fragments together.
A path with an unknown number of segments is a subpath route: pass subpath = true to the annotation and take a cask.RemainingPathSegments
parameter, whose value is a Seq[String]:
@cask.get("/files", subpath = true)
def files(rest: cask.RemainingPathSegments): String = rest.value.mkString("|")
$ curl -i localhost:53767/files/a/b/c
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 5
a|b|c
Writing a wildcard in the path ("/files/*") is a trap: it is read as a literal * segment, so the route never matches and the request
is a 404 with the body Error 404: Not Found. The framework gives no warning at startup. The same body is returned for any unknown path, and a
known path with the wrong HTTP method gets a 405:
$ curl -i -X POST localhost:53767/trails/pico-loop
HTTP/1.1 405 Method Not Allowed
Content-Type: text/plain; charset=utf-8
Content-Length: 29
Error 405: Method Not Allowed
Overriding handleNotFound and handleEndpointError in the application object customizes those two responses (not run here).
HTML with Scalatags
To return HTML, Cask works with the Scalatags library (add //> using dep com.lihaoyi::scalatags:0.13.1). A route that returns
Scalatags' doctype value is sent as text/html automatically, so there is no header to set by hand:
import scalatags.Text.all.*
@cask.get("/page/:name")
def page(name: String): doctype =
doctype("html")(html(body(h1(name), p(s"${trails.size} trails known"))))
$ curl -i localhost:53767/page/Hikes
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: text/html
<!DOCTYPE html><html><body><h1>Hikes</h1><p>3 trails known</p></body></html>
Scalatags escapes text content (h1(name) would turn < into <), which is the main reason to prefer it over string interpolation
when a value comes from a request. The import scalatags.Text.all.* brings in a very large number of short names (html, body, p, a, div,
name, id …), so keep it local to the file or method that builds HTML; here name the parameter shadows Scalatags' name, which compiles
because the parameter is the nearer binding.
When the page is a fixed string, the other way is a text block with the header set by hand:
@cask.get("/form")
def getForm(): cask.Response[String] =
val page = """<!doctype html><form action="/hike" method="post">
|<input name="name"><input name="trail"><input type="submit"></form>""".stripMargin
cask.Response(page, headers = Seq("Content-Type" -> "text/html"))
The strings in Seq("Content-Type" -> "text/html") are ordinary pairs. Without the header the browser would show the markup as text, because a
String result is text/plain.
Query parameters
A method parameter whose name is not a path segment is read from the query string. Declaring it as Option[T] with a default of None makes it
optional; one without a default is required:
@cask.get("/trails")
def search(maxKm: Option[Double] = None, difficulty: Option[String] = None): String =
trails
.filter(t => maxKm.forall(t.lengthKm <= _))
.filter(t => difficulty.forall(_ == t.difficulty))
.map(_.name)
.mkString(", ")
$ curl -i "localhost:53767/trails?maxKm=10"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 20
Pico Loop, Lake Walk
$ curl -i "localhost:53767/trails?maxKm=10&difficulty=easy"
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 9
Lake Walk
A value that does not parse is rejected before the method runs, with a 400:
$ curl -i "localhost:53767/trails?maxKm=abc"
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 22
Error 400: Bad Request
(With debugMode left at its default true, the same request returns about 3 KB of text: the message
maxKm: Option[Double] = "ArraySeq(abc)" failed to parse with java.lang.NumberFormatException, the stack trace, and the "expected signature" of
the route. That is the reason to turn it off.) A parameter repeated in the query string is read into a Seq[String]: with def tags(tag: Seq[String]): String = tag.mkString("+"), the request
/tags?tag=a&tag=b answers a+b and /tags without the parameter is a 400. A cask.QueryParams parameter receives all of them as a map (not run here).
Form input
@cask.postForm handles an HTML form posted as application/x-www-form-urlencoded (and multipart). The parameter names must equal the field names:
@cask.postForm("/hike")
def hikeForm(name: String, trail: String): String = s"$name will hike $trail"
$ curl -i -d "name=Ana&trail=Pico" localhost:53767/hike
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 18
Ana will hike Pico
$ curl -i -d "name=Ana" localhost:53767/hike
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 130
Missing argument: (trail: String)
Arguments provided did not match expected signature:
hikeForm
name String
trail String
A missing required field is a 400 that names it, even with debugMode off; this message is the most useful one for debugging a form. Plain
@cask.post is the variant that gives you the raw cask.Request, with the body in request.text() or request.readAllBytes(); file uploads
arrive as cask.FormFile parameters of a postForm route (not run here).
JSON input and output
@cask.postJson reads a JSON object body and binds each top-level field to the parameter of the same name, converted by uPickle:
@cask.postJson("/book")
def book(name: String, people: Int): String = s"booked $people for $name"
$ curl -i -H "Content-Type: application/json" -d '{"name":"Ana","people":3}' localhost:53767/book
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json
"booked 3 for Ana"
$ curl -i -H "Content-Type: application/json" -d '{"name":"Ana"}' localhost:53767/book
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Content-Length: 122
Missing argument: (people: Int)
Arguments provided did not match expected signature:
book
name String
people Int
Two details from the real response. The String result of a postJson route is itself sent as JSON (Content-Type: application/json, and the
text is quoted), unlike the same String from a get route; the Toolkit text does not mention it. And a field of the wrong type ("people":"x")
is also a 400 with Error 400: Bad Request when debugMode is off. As in the guide, a body with extra or missing fields is rejected.
When the shape is not known in advance, take a ujson.Value and use uPickle’s tree API (see
JSON with uPickle). The parameter is the field named value in the body:
@cask.postJson("/free")
def free(value: ujson.Value): String = value.toString
$ curl -i -H "Content-Type: application/json" -d '{"value":{"a":1}}' localhost:53767/free
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Content-Type: application/json
"{\"a\":1}"
Returning a ujson.Value produces JSON output, with the content type set for you:
@cask.get("/api/trails")
def trailsJson(): ujson.Value =
ujson.Arr.from(trails.map(t => ujson.Obj("name" -> t.name, "km" -> t.lengthKm)))
$ curl -i localhost:53767/api/trails
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 95
[{"name":"Pico Loop","km":9.5},{"name":"Lake Walk","km":3.2},{"name":"Ridge Traverse","km":14}]
Note that 14.0 is written as 14, because uPickle writes whole doubles without a fraction. For case classes, derive a ReadWriter with derives
(Scala 3) as covered on Type Class Derivation and
Case Classes, and return upickle.default.writeJs(value); Scala 2 needs
implicit val rw: ReadWriter[T] = macroRW in the companion.
Cookies
Cookies are set through the cookies parameter of cask.Response and read either as a cask.Cookie method parameter (the parameter name is the cookie
name, and a missing cookie is a 400) or from request.cookies. The next fragment is a login that hands out a session id; the decorator
section below uses it:
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
private val sessions = ConcurrentHashMap.newKeySet[String]()
@cask.postForm("/login")
def login(user: String): cask.Response[String] =
val id = UUID.randomUUID().toString
sessions.add(id)
cask.Response("welcome", cookies = Seq(cask.Cookie("sessionId", id, httpOnly = true)))
$ curl -i -d "user=ana" localhost:53767/login
HTTP/1.1 200 OK
Set-Cookie: sessionId=<a random UUID>; Version=1; HttpOnly; SameSite=Lax
Content-Type: text/plain; charset=utf-8
Content-Length: 7
welcome
The id changes on every run, so it is replaced by a placeholder above. cask.Cookie takes name, value, and optional comment, domain, expires, maxAge,
path, version, discard, httpOnly, secure and sameSite; only httpOnly = true was set here, and the output shows that Cask adds
SameSite=Lax on its own. To delete a cookie, send it again with expires = java.time.Instant.EPOCH. Use secure = true as soon as
the service is served over HTTPS. The state here is an in-memory set, which is lost on restart and is not shared between servers; the Toolkit guide
does the same. A real service keeps sessions in a database or uses signed tokens, and does not handle passwords in plain text forms
(the Toolkit sample does, and its type="text" password field is only a demonstration).
Decorators
A decorator wraps an endpoint: it runs before the route and can reject the request, change it or add arguments. It is a class that extends
cask.RawDecorator, applied as an annotation in front of the endpoint’s own annotation. The request goes through it like this:
In code, the decorator checks the cookie and either calls delegate or returns a response itself. Values passed in the Map arrive in the
endpoint’s last, separate parameter list:
class loggedIn extends cask.RawDecorator:
override def wrapFunction(
ctx: cask.Request,
delegate: (cask.Request, Map[String, Any]) => cask.router.Result[cask.Response.Raw]
): cask.router.Result[cask.Response.Raw] =
ctx.cookies.get("sessionId") match
case Some(c) if sessions.contains(c.value) => delegate(ctx, Map("sessionId" -> c.value))
case _ => cask.router.Result.Success(cask.Response("not logged in", 403))
@loggedIn()
@cask.get("/private")
def secured()(sessionId: String): String = s"hello ${sessionId.take(4)}..."
$ curl -i localhost:53767/private
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
Content-Length: 13
not logged in
$ curl -c jar -d "user=ana" localhost:53767/login
$ curl -i -b jar localhost:53767/private
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 13
hello 8c00...
The -c jar and -b jar options save and send back the cookie. The first four characters of the id are random, so those of your run will differ.
The same mechanism provides rate limiting, logging and timing; Cask ships cask.decorators.compress as a ready-made one that
compresses the response.
The Toolkit guide writes the signature with the short names Delegate, Result[Raw] and passes delegate(Map(...)). Those names and that call do
not compile against Cask 0.11.3: the compiler reports Not found: type Result and Not found: type Raw (both E006) and, for the call
delegate(Map("sessionId" -> c.value)), Found: Map[String, String] Required: cask.model.Request (E007), because the delegate now takes the
request as its first argument, as written above. Use the spelled-out signature.
Source: Scala Toolkit: Using decorators
Websockets
A websocket route returns a cask.WsHandler, which receives the connection’s channel, and the handler builds a cask.WsActor, a partial
function over incoming events. The cask.Ws.Text, cask.Ws.Binary, cask.Ws.Ping, cask.Ws.Pong, cask.Ws.Close, cask.Ws.Error and cask.Ws.ChannelClosed events
are the cases (see Pattern Matching):
@cask.websocket("/ws")
def ws(): cask.WsHandler =
cask.WsHandler { channel =>
cask.WsActor {
case cask.Ws.Text("") => channel.send(cask.Ws.Close())
case cask.Ws.Text(name) =>
channel.send(cask.Ws.Text(trails.find(_.name == name).fold("unknown")(_.difficulty)))
}
}
The server answers a trail name with its difficulty and closes the connection on an empty message. To exercise it, here is a client that
needs no dependency, using the JDK’s java.net.http.WebSocket (Java interoperability as on
Using Java from Scala):
//> using scala 3.10.0
import java.net.URI
import java.net.http.{HttpClient, WebSocket}
import java.util.concurrent.{CompletableFuture, CompletionStage, CountDownLatch, TimeUnit}
@main def client(port: String): Unit =
val done = CountDownLatch(1)
val listener = new WebSocket.Listener:
override def onText(ws: WebSocket, data: CharSequence, last: Boolean): CompletionStage[?] =
println(s"received: $data")
ws.request(1)
CompletableFuture.completedFuture(null)
override def onClose(ws: WebSocket, code: Int, reason: String): CompletionStage[?] =
println(s"closed: $code")
done.countDown()
CompletableFuture.completedFuture(null)
val ws = HttpClient.newHttpClient().newWebSocketBuilder()
.buildAsync(URI.create(s"ws://localhost:$port/ws"), listener).join()
ws.request(1)
for name <- List("Pico Loop", "Nowhere") do
ws.sendText(name, true).join()
Thread.sleep(300)
ws.sendText("", true).join()
done.await(5, TimeUnit.SECONDS)
Run against the server with scala run . — 53767 from the client’s directory:
received: moderate
received: unknown
closed: 1000
The Toolkit guide also serves a static HTML page whose JavaScript opens the websocket; that browser part was not run here. The browser code is the same
new WebSocket("ws://localhost:8080/ws") that any websocket server uses. Cask’s cask.WsClient can also act as a client; the JDK client was used
here so that the check does not depend on the library under test.
Pitfalls
-
Forgetting
initialize(). The routes are registered by that call; without it every request is a 404. -
Wildcards in the path.
"/files/*"is a literal; usesubpath = trueandcask.RemainingPathSegments. -
Parameter name versus path name. A
:citysegment is matched to the parametercity; if the names differ the parameter is read from the query string instead and the request fails with a 400 for a missing argument. -
debugModeleft on. Stack traces and route signatures are sent back to the caller. -
Relative
staticFilesdirectories. They depend on the working directory; preferstaticResourcesfor packaged applications. -
Blocking handlers. Cask runs each request on an Undertow worker thread, so a slow call blocks one thread; offload long work (see Futures & Promises).
-
Ports. Starting a second server on a port that is in use fails at startup; the examples above pick a free port with
--java-prop. -
In-memory state. Anything held in a
valof the application object is shared by all concurrent requests and must be thread safe (hence theConcurrentHashMapset above).
Tests of a Cask application run the server on a free port and use sttp or requests, with the structure described on
Testing with MUnit and
HTTP Client with sttp (not run here).
Where the Toolkit guide is outdated
-
It pins Cask 0.10.2; 0.11.3 is the current release on Maven Central and compiles and runs on Scala 3.10.0 and JDK 26 without changes to the routes.
-
The decorator sample (
Delegate,Result[Raw],delegate(Map(...))) does not compile against 0.11.3, as shown above. -
Its Scala 3 samples keep Scala 2 habits, such as the explicit
initialize()call at the end (still required),import scala.jdk.CollectionConverters._(use*) and braces. Thebuild.scMill snippets use the older Mill 0.x format. -
It does not say that
postJsonwrites aStringresult as JSON, thatstaticFilesis relative to the working directory or thatdebugModereturns stack traces. -
It uses a hard-coded
user/passwordlogin for illustration; do not copy it into a real service.