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:

sequenceDiagram participant C as Client participant U as Undertow participant M as Main dispatch participant D as loggedIn decorator participant E as get /private participant H as secured method C->>U: GET /private (Cookie sessionId) U->>M: handleRequest M->>D: wrapFunction(request, delegate) alt cookie is a known session D->>E: delegate(request, sessionId map) E->>H: secured()(sessionId) H-->>E: String E-->>D: Response 200 D-->>M: Response 200 else no valid cookie D-->>M: Response 403, route not called end M-->>U: Response U-->>C: HTTP response

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.

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; use subpath = true and cask.RemainingPathSegments.

  • Parameter name versus path name. A :city segment is matched to the parameter city; if the names differ the parameter is read from the query string instead and the request fails with a 400 for a missing argument.

  • debugMode left on. Stack traces and route signatures are sent back to the caller.

  • Relative staticFiles directories. They depend on the working directory; prefer staticResources for 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 val of the application object is shared by all concurrent requests and must be thread safe (hence the ConcurrentHashMap set 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. The build.sc Mill snippets use the older Mill 0.x format.

  • It does not say that postJson writes a String result as JSON, that staticFiles is relative to the working directory or that debugMode returns stack traces.

  • It uses a hard-coded user/password login for illustration; do not copy it into a real service.