sun, 22:43 at the minemoon, waxing crescent, 36% lit - 22:43 at the mine(spicemine)▤▧▥

sign inEN日本

mélangej · ABCL on Jooby and Netty

Quickstart

What you need

A JDK 17 or newer. 17 is what the build compiles for and what production runs; Jooby 3's classes are compiled for 17, so an older Java does not get a slower server, it gets UnsupportedClassVersionError from inside a fasl load. The examples are checked on 17, 21 and 26, and examples/hello/run.sh refuses 11 with a sentence instead of that trace. Any build of OpenJDK will do:

brew install openjdk@17                      # macOS
sudo apt install openjdk-17-jdk-headless     # Debian, Ubuntu
nix-shell -p openjdk17_headless              # Nix
export JAVA_HOME=$(/usr/libexec/java_home -v 17)   # macOS, if java is not 17

A JRE 17 is enough to run a release tarball; building from source needs the JDK. The Gradle wrapper (8.10.2) starts on the JDK you have - checked on 11, 17 and 21 - and if that is not a 17 it downloads one to compile with, into ~/.gradle/jdks, once.

Everything else is either fetched for you or optional:

  • Gradle, ABCL, Jooby, Netty, the JDBC drivers - for building; core/gradlew fetches them.
  • fossil - to clone the source, and to host fossil repositories (fossil-scm.org).
  • curl and sha256sum (shasum -a 256 on macOS) - for the release tarball; usually there already.
  • hg, jj, lore - only for the engines you host.
  • PostgreSQL 16 - optional, for meta.db on Postgres; SQLite is built in.

git needs nothing: it is served in-process, by JGit. An engine whose program is missing is offered greyed out on the new-repository form and refused by the API, rather than half-made.

Get it and run it

Two ways, because one way to fetch software is not enough.

With fossil, to get the source and its history:

fossil clone https://HOST/thecommons/spicemine spicemine.fossil   # HOST: the instance you are reading this on
mkdir spicemine && cd spicemine && fossil open ../spicemine.fossil

core/gradlew -p core installDist          # builds and compiles the Lisp
core/build/install/spicemine/bin/spicemine-server

That is the whole install. The first build downloads Gradle and the jars; after that it is about twenty seconds. The server comes up on :8643 and prints a first-boot admin token once.

With curl, to get a built release and nothing else — no fossil binary, no JDK, no Gradle, no build:

host=https://spicemine.example.org
rel=$(curl -fsS "$host/dist/LATEST")          # e.g. spicemine-0.6.0.tar.gz

curl -fsSO "$host/dist/$rel"
curl -fsSO "$host/dist/$rel.sha256"
sha256sum -c "$rel.sha256"                    # verify before unpacking

tar xzf "$rel"
spicemine-*/bin/spicemine-server

Verify the checksum, every time. It is two lines and it is the only part of this that distinguishes a release from a file that arrived over the network. A JRE 17 or newer is the one thing the tarball still needs.

Any instance can serve its own releases; deploy.sh stages them. An instance that has never staged one has no /dist route at all, so a 404 there means that instance does not publish builds rather than that the release is missing.

Why two: the fossil server runs a browser check against crawlers, which a human with a browser passes without noticing and curl cannot pass at all. So the clone needs the fossil client, and the tarball needs nothing. Both get you the same code — the tarball is the artifact built from the source the clone gives you.

Three environment variables matter:

export JAVA_OPTS="-Xmx256m -Dspice.port=8643 -Dspice.home=/var/lib/spicemine"

spice.home is where repositories and meta.db live; everything else has a default. Chapter 10 covers running it as a service.

Just the pieces

If you want mélangej without spicemine, you need four things on a classpath: abcl, abcl-contrib, jooby, jooby-netty and Netty's transitives. In Gradle:

dependencies {
    implementation 'org.abcl:abcl:1.9.2'
    implementation 'org.abcl:abcl-contrib:1.9.2'
    implementation 'io.jooby:jooby:3.9.1'
    implementation 'io.jooby:jooby-netty:3.9.1'
}

Hello

Two versions of the same server. The first is how you would write it: a handler is a function of the request, and an application is a list of routes. The second is the same server with nothing on top - Jooby and the java: package - so that the bottom is something you have seen rather than something you take on trust.

The short way

(defpackage :short
  (:use :cl :spice/http)
  (:import-from :spice/html :html))
(in-package :short)

;;; A handler is a function of the request context that returns the body.
(defun hello (ctx)
  (html (list :h1 "hello, " (ctx-query ctx "who" "world"))))

(defun hello-name (ctx)
  (html (list :p "hello, " (ctx-param ctx "name"))))

;;; The application is a list. Symbols, so a redefined HELLO is live
;;; without a restart.
(serve '((:get "/"             hello)
         (:get "/hello/{name}" hello-name))
       :port (property "hello.port" 8080)
       :wait t)
core/gradlew -p core installDist           # once: the distribution
examples/hello/run.sh short.lisp           # http://localhost:8650/

run.sh loads the framework first - examples/melangej.lisp, three loads out of the distribution - so the file above is the whole application. Open /?who=<script> and the tag arrives as text: html escapes every string it is given.

What is going on, one line at a time:

  • A route is (method path handler), and the application is a list of them. It is data, so it can be built with list, joined with append, filtered with remove-if, or printed to see what is mounted.
  • A symbol for the handler is looked up on every request. Redefine hello at a REPL and the next request runs the new one, with no restart. #'hello or a lambda is fixed when the route is mounted.
  • Answers are HTML (text/html; charset=utf-8) unless a route says otherwise: (:get "/api" api :type "application/json"). Jooby's own default is text/plain, which a browser shows as source.
  • serve's keywords are the server's options: :port, :io-threads, :worker-threads, :type (the default for every route) and :wait, which keeps a script started with --load from ending - and taking the server with it. Without :wait, serve returns the running server.
  • (property "hello.port" 8080) reads -Dhello.port=..., as an integer because the default is one. run.sh sets it from PORT.
  • (ctx-query ctx "who" "world") is the query parameter or the default.

When the handler is short enough to sit in the table, routes writes the lambdas:

(serve (routes
         (:get "/"             (ctx) (html (list :h1 "hello")))
         (:get "/hello/{name}" (ctx) (ctx-param ctx "name")))
       :port 8080 :wait t)

Both are ordinary lists, so (append '((:get "/" hello)) (routes ...)) mixes them.

The whole of it:

  (serve routes &key port io-threads worker-threads type wait)
                         mount, start, and return the server (or wait)
  (mount app routes &key type)
                         mount on an app you made with make-app; returns it
  (routes (method path (ctx) body...) ...)
                         a route list from inline handlers
  (property name &optional default)
                         a -D system property
  route, def-route, start-server
                         the same, one route at a time

The same thing by hand

Nothing to require. The whole Java surface here is the java: package that is part of ABCL - no abcl-contrib, no jss, no reader macro. That is also why a server built this way starts in about two seconds: the nine it used to take were those two requires (chapter 11).

(defvar *routes* 0)

(defun make-handler (fn)
  ;; Jooby reads the bytecode of apply(Context) to learn the return type,
  ;; and a Lisp closure has no bytecode to read. So: generate a class with
  ;; a String-typed apply next to the one that actually runs.
  (let ((class
          (java:jnew-runtime-class
           (format nil "Route~d" (incf *routes*))
           ;; Named, because the default loader's parent is whatever is
           ;; current, and before ABCL has compiled anything that is the
           ;; JVM's own, which this constructor does not take.
           :class-loader (java::make-memory-class-loader (java:get-default-classloader))
           :interfaces '("io.jooby.Route$Handler")
           :methods `(("apply" "java.lang.String" ("io.jooby.Context")
                       (lambda (this ctx) (declare (ignore this ctx)) nil))
                      ("apply" "java.lang.Object" ("io.jooby.Context")
                       (lambda (this ctx) (declare (ignore this))
                         (funcall ,fn ctx)))))))
    ;; The class OBJECT, not its name: a runtime class lives in ABCL's own
    ;; class loader and `(java:jnew "Route1")` cannot find it.
    (java:jnew class)))

(let ((app (java:jnew "io.jooby.Jooby"))
      (options (java:jnew "io.jooby.ServerOptions")))
  (java:jcall "route" app "GET" "/"
              (make-handler (lambda (ctx)
                              (java:jcall "setResponseType" ctx "text/html")
                              "<h1>hello from Lisp</h1>")))
  (java:jcall "setPort" options
              (parse-integer (java:jstatic "getProperty" "java.lang.System"
                                           "hello.port" "8080")))
  (java:jcall "setServerOptions" app options)
  (java:jcall "start" app)
  ;; --load returns once this form has run; this keeps the process up.
  (loop (sleep 3600)))
java -cp "core/build/install/spicemine/lib/*" -Dhello.port=8650 \
     org.armedbear.lisp.Main --noinform --load examples/hello/by-hand.lisp

The :class-loader line is the one you would not guess. ABCL's default is a loader whose parent is whatever is current, and until the image has compiled something that is the JVM's own, which the constructor does not accept: without the line, jnew-runtime-class fails with NoSuchMethodException: MemoryClassLoader(AppClassLoader). A server that compiles anything before its first route never sees it; a five-line script does. This page showed the block without it until 0.34.0, and it did not run.

That is the framework. Everything else is a decision about what goes inside the handler.

A bigger example

For something to start from, the same directory has hello.lisp:

examples/hello/run.sh                   # http://localhost:8650/

About a hundred lines, three routes, and it loads three files out of the distribution rather than all thirty-three — an application that does not serve git has no use for the git adapter.

It shows the two ways to produce HTML side by side: / builds the page from s-expressions, /tmpl renders the same page from hello.html, an ordinary HTML file with Lisp in <? ?> tags, and /api returns JSON. Chapter 5 is about the difference.

The escaping is demonstrated rather than claimed. Open /?who=<script>alert(1)</script> and read the source: the tag arrives as text, in both renderers, because neither of them has a way to emit it as markup.

The one awkward part, kept in one place

make-handler above is the hand-written twin of %make-handler, the only function in the entire project that touches jnew-runtime-class. spice-http.lisp exports serve, route, def-route and about twenty context accessors, and no other file calls Java at all. If ABCL's interop changes, one file changes.

(def-route (app "GET" "/hello/{name}") (ctx)
  (format nil "hello, ~a" (ctx-param ctx "name")))

Adding the rest

boot.lisp loads the layers in order, and each is optional:

(load-part "spice-db")      ; (db-open path) (db-query "select ...")
(load-part "spice-auth")    ; (hash-password p) (check-password p hash)
(load-part "spice-html")    ; (html (:p "escaped by default"))
(load-part "spice-i18n")    ; (tr :key)
(load-part "spice-skin")    ; (skin-style)

load-part prefers a compiled NAME.abcl and falls back to NAME.lisp. That difference is worth a factor of three — chapter 11.

A page

(def-route (app "GET" "/repos") (ctx)
  (ctx-set-type ctx "text/html; charset=utf-8")
  (html (list :h1 "repositories")
        (list :table
              (loop for (name engine) in (db-query "select slug, engine from repos")
                    collect (list :tr
                                  (list :td name)
                                  (list :td (list :span :class "chip" engine)))))))

No template file, no context dictionary, nothing to remember about escaping. The loop is a child because a list of nodes splices; name is escaped because strings are escaped; and the whole thing is a function you can call from a REPL and read.

Two ways to run it while working

core/gradlew -p core runCore -Pport=8643    # from the tree, loads .lisp sources
core/gradlew -p core installDist            # packaged, compiled to fasls

The first is what you keep open; the second is what you deploy.

← Which Lisp this isRouting and the request →